aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSam Chudnick <sam@chudnick.com>2022-07-17 20:20:23 -0400
committerSam Chudnick <sam@chudnick.com>2022-07-17 20:20:23 -0400
commit6a93794737981247a1acb72704c172ef858153ad (patch)
tree0a8a879ace1eecf8511681c453edd0cbbc0cdd00
Initial commit
-rw-r--r--LICENSE674
-rw-r--r--Makefile18
-rw-r--r--README.md94
-rw-r--r--config.ini32
-rw-r--r--config.py21
-rw-r--r--database.py64
-rw-r--r--interface.py626
-rw-r--r--jellyfin.py203
-rw-r--r--jellyfin_apiclient_python/__init__.py133
-rw-r--r--jellyfin_apiclient_python/api.py653
-rw-r--r--jellyfin_apiclient_python/client.py87
-rw-r--r--jellyfin_apiclient_python/configuration.py53
-rw-r--r--jellyfin_apiclient_python/connection_manager.py379
-rw-r--r--jellyfin_apiclient_python/credentials.py128
-rw-r--r--jellyfin_apiclient_python/exceptions.py11
-rw-r--r--jellyfin_apiclient_python/http.py267
-rw-r--r--jellyfin_apiclient_python/keepalive.py20
-rw-r--r--jellyfin_apiclient_python/timesync_manager.py140
-rw-r--r--jellyfin_apiclient_python/ws_client.py140
-rwxr-xr-xjoc.py41
20 files changed, 3784 insertions, 0 deletions
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..94a9ed0
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
1 GNU GENERAL PUBLIC LICENSE
2 Version 3, 29 June 2007
3
4 Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
5 Everyone is permitted to copy and distribute verbatim copies
6 of this license document, but changing it is not allowed.
7
8 Preamble
9
10 The GNU General Public License is a free, copyleft license for
11software and other kinds of works.
12
13 The licenses for most software and other practical works are designed
14to take away your freedom to share and change the works. By contrast,
15the GNU General Public License is intended to guarantee your freedom to
16share and change all versions of a program--to make sure it remains free
17software for all its users. We, the Free Software Foundation, use the
18GNU General Public License for most of our software; it applies also to
19any other work released this way by its authors. You can apply it to
20your programs, too.
21
22 When we speak of free software, we are referring to freedom, not
23price. Our General Public Licenses are designed to make sure that you
24have the freedom to distribute copies of free software (and charge for
25them if you wish), that you receive source code or can get it if you
26want it, that you can change the software or use pieces of it in new
27free programs, and that you know you can do these things.
28
29 To protect your rights, we need to prevent others from denying you
30these rights or asking you to surrender the rights. Therefore, you have
31certain responsibilities if you distribute copies of the software, or if
32you modify it: responsibilities to respect the freedom of others.
33
34 For example, if you distribute copies of such a program, whether
35gratis or for a fee, you must pass on to the recipients the same
36freedoms that you received. You must make sure that they, too, receive
37or can get the source code. And you must show them these terms so they
38know their rights.
39
40 Developers that use the GNU GPL protect your rights with two steps:
41(1) assert copyright on the software, and (2) offer you this License
42giving you legal permission to copy, distribute and/or modify it.
43
44 For the developers' and authors' protection, the GPL clearly explains
45that there is no warranty for this free software. For both users' and
46authors' sake, the GPL requires that modified versions be marked as
47changed, so that their problems will not be attributed erroneously to
48authors of previous versions.
49
50 Some devices are designed to deny users access to install or run
51modified versions of the software inside them, although the manufacturer
52can do so. This is fundamentally incompatible with the aim of
53protecting users' freedom to change the software. The systematic
54pattern of such abuse occurs in the area of products for individuals to
55use, which is precisely where it is most unacceptable. Therefore, we
56have designed this version of the GPL to prohibit the practice for those
57products. If such problems arise substantially in other domains, we
58stand ready to extend this provision to those domains in future versions
59of the GPL, as needed to protect the freedom of users.
60
61 Finally, every program is threatened constantly by software patents.
62States should not allow patents to restrict development and use of
63software on general-purpose computers, but in those that do, we wish to
64avoid the special danger that patents applied to a free program could
65make it effectively proprietary. To prevent this, the GPL assures that
66patents cannot be used to render the program non-free.
67
68 The precise terms and conditions for copying, distribution and
69modification follow.
70
71 TERMS AND CONDITIONS
72
73 0. Definitions.
74
75 "This License" refers to version 3 of the GNU General Public License.
76
77 "Copyright" also means copyright-like laws that apply to other kinds of
78works, such as semiconductor masks.
79
80 "The Program" refers to any copyrightable work licensed under this
81License. Each licensee is addressed as "you". "Licensees" and
82"recipients" may be individuals or organizations.
83
84 To "modify" a work means to copy from or adapt all or part of the work
85in a fashion requiring copyright permission, other than the making of an
86exact copy. The resulting work is called a "modified version" of the
87earlier work or a work "based on" the earlier work.
88
89 A "covered work" means either the unmodified Program or a work based
90on the Program.
91
92 To "propagate" a work means to do anything with it that, without
93permission, would make you directly or secondarily liable for
94infringement under applicable copyright law, except executing it on a
95computer or modifying a private copy. Propagation includes copying,
96distribution (with or without modification), making available to the
97public, and in some countries other activities as well.
98
99 To "convey" a work means any kind of propagation that enables other
100parties to make or receive copies. Mere interaction with a user through
101a computer network, with no transfer of a copy, is not conveying.
102
103 An interactive user interface displays "Appropriate Legal Notices"
104to the extent that it includes a convenient and prominently visible
105feature that (1) displays an appropriate copyright notice, and (2)
106tells the user that there is no warranty for the work (except to the
107extent that warranties are provided), that licensees may convey the
108work under this License, and how to view a copy of this License. If
109the interface presents a list of user commands or options, such as a
110menu, a prominent item in the list meets this criterion.
111
112 1. Source Code.
113
114 The "source code" for a work means the preferred form of the work
115for making modifications to it. "Object code" means any non-source
116form of a work.
117
118 A "Standard Interface" means an interface that either is an official
119standard defined by a recognized standards body, or, in the case of
120interfaces specified for a particular programming language, one that
121is widely used among developers working in that language.
122
123 The "System Libraries" of an executable work include anything, other
124than the work as a whole, that (a) is included in the normal form of
125packaging a Major Component, but which is not part of that Major
126Component, and (b) serves only to enable use of the work with that
127Major Component, or to implement a Standard Interface for which an
128implementation is available to the public in source code form. A
129"Major Component", in this context, means a major essential component
130(kernel, window system, and so on) of the specific operating system
131(if any) on which the executable work runs, or a compiler used to
132produce the work, or an object code interpreter used to run it.
133
134 The "Corresponding Source" for a work in object code form means all
135the source code needed to generate, install, and (for an executable
136work) run the object code and to modify the work, including scripts to
137control those activities. However, it does not include the work's
138System Libraries, or general-purpose tools or generally available free
139programs which are used unmodified in performing those activities but
140which are not part of the work. For example, Corresponding Source
141includes interface definition files associated with source files for
142the work, and the source code for shared libraries and dynamically
143linked subprograms that the work is specifically designed to require,
144such as by intimate data communication or control flow between those
145subprograms and other parts of the work.
146
147 The Corresponding Source need not include anything that users
148can regenerate automatically from other parts of the Corresponding
149Source.
150
151 The Corresponding Source for a work in source code form is that
152same work.
153
154 2. Basic Permissions.
155
156 All rights granted under this License are granted for the term of
157copyright on the Program, and are irrevocable provided the stated
158conditions are met. This License explicitly affirms your unlimited
159permission to run the unmodified Program. The output from running a
160covered work is covered by this License only if the output, given its
161content, constitutes a covered work. This License acknowledges your
162rights of fair use or other equivalent, as provided by copyright law.
163
164 You may make, run and propagate covered works that you do not
165convey, without conditions so long as your license otherwise remains
166in force. You may convey covered works to others for the sole purpose
167of having them make modifications exclusively for you, or provide you
168with facilities for running those works, provided that you comply with
169the terms of this License in conveying all material for which you do
170not control copyright. Those thus making or running the covered works
171for you must do so exclusively on your behalf, under your direction
172and control, on terms that prohibit them from making any copies of
173your copyrighted material outside their relationship with you.
174
175 Conveying under any other circumstances is permitted solely under
176the conditions stated below. Sublicensing is not allowed; section 10
177makes it unnecessary.
178
179 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180
181 No covered work shall be deemed part of an effective technological
182measure under any applicable law fulfilling obligations under article
18311 of the WIPO copyright treaty adopted on 20 December 1996, or
184similar laws prohibiting or restricting circumvention of such
185measures.
186
187 When you convey a covered work, you waive any legal power to forbid
188circumvention of technological measures to the extent such circumvention
189is effected by exercising rights under this License with respect to
190the covered work, and you disclaim any intention to limit operation or
191modification of the work as a means of enforcing, against the work's
192users, your or third parties' legal rights to forbid circumvention of
193technological measures.
194
195 4. Conveying Verbatim Copies.
196
197 You may convey verbatim copies of the Program's source code as you
198receive it, in any medium, provided that you conspicuously and
199appropriately publish on each copy an appropriate copyright notice;
200keep intact all notices stating that this License and any
201non-permissive terms added in accord with section 7 apply to the code;
202keep intact all notices of the absence of any warranty; and give all
203recipients a copy of this License along with the Program.
204
205 You may charge any price or no price for each copy that you convey,
206and you may offer support or warranty protection for a fee.
207
208 5. Conveying Modified Source Versions.
209
210 You may convey a work based on the Program, or the modifications to
211produce it from the Program, in the form of source code under the
212terms of section 4, provided that you also meet all of these conditions:
213
214 a) The work must carry prominent notices stating that you modified
215 it, and giving a relevant date.
216
217 b) The work must carry prominent notices stating that it is
218 released under this License and any conditions added under section
219 7. This requirement modifies the requirement in section 4 to
220 "keep intact all notices".
221
222 c) You must license the entire work, as a whole, under this
223 License to anyone who comes into possession of a copy. This
224 License will therefore apply, along with any applicable section 7
225 additional terms, to the whole of the work, and all its parts,
226 regardless of how they are packaged. This License gives no
227 permission to license the work in any other way, but it does not
228 invalidate such permission if you have separately received it.
229
230 d) If the work has interactive user interfaces, each must display
231 Appropriate Legal Notices; however, if the Program has interactive
232 interfaces that do not display Appropriate Legal Notices, your
233 work need not make them do so.
234
235 A compilation of a covered work with other separate and independent
236works, which are not by their nature extensions of the covered work,
237and which are not combined with it such as to form a larger program,
238in or on a volume of a storage or distribution medium, is called an
239"aggregate" if the compilation and its resulting copyright are not
240used to limit the access or legal rights of the compilation's users
241beyond what the individual works permit. Inclusion of a covered work
242in an aggregate does not cause this License to apply to the other
243parts of the aggregate.
244
245 6. Conveying Non-Source Forms.
246
247 You may convey a covered work in object code form under the terms
248of sections 4 and 5, provided that you also convey the
249machine-readable Corresponding Source under the terms of this License,
250in one of these ways:
251
252 a) Convey the object code in, or embodied in, a physical product
253 (including a physical distribution medium), accompanied by the
254 Corresponding Source fixed on a durable physical medium
255 customarily used for software interchange.
256
257 b) Convey the object code in, or embodied in, a physical product
258 (including a physical distribution medium), accompanied by a
259 written offer, valid for at least three years and valid for as
260 long as you offer spare parts or customer support for that product
261 model, to give anyone who possesses the object code either (1) a
262 copy of the Corresponding Source for all the software in the
263 product that is covered by this License, on a durable physical
264 medium customarily used for software interchange, for a price no
265 more than your reasonable cost of physically performing this
266 conveying of source, or (2) access to copy the
267 Corresponding Source from a network server at no charge.
268
269 c) Convey individual copies of the object code with a copy of the
270 written offer to provide the Corresponding Source. This
271 alternative is allowed only occasionally and noncommercially, and
272 only if you received the object code with such an offer, in accord
273 with subsection 6b.
274
275 d) Convey the object code by offering access from a designated
276 place (gratis or for a charge), and offer equivalent access to the
277 Corresponding Source in the same way through the same place at no
278 further charge. You need not require recipients to copy the
279 Corresponding Source along with the object code. If the place to
280 copy the object code is a network server, the Corresponding Source
281 may be on a different server (operated by you or a third party)
282 that supports equivalent copying facilities, provided you maintain
283 clear directions next to the object code saying where to find the
284 Corresponding Source. Regardless of what server hosts the
285 Corresponding Source, you remain obligated to ensure that it is
286 available for as long as needed to satisfy these requirements.
287
288 e) Convey the object code using peer-to-peer transmission, provided
289 you inform other peers where the object code and Corresponding
290 Source of the work are being offered to the general public at no
291 charge under subsection 6d.
292
293 A separable portion of the object code, whose source code is excluded
294from the Corresponding Source as a System Library, need not be
295included in conveying the object code work.
296
297 A "User Product" is either (1) a "consumer product", which means any
298tangible personal property which is normally used for personal, family,
299or household purposes, or (2) anything designed or sold for incorporation
300into a dwelling. In determining whether a product is a consumer product,
301doubtful cases shall be resolved in favor of coverage. For a particular
302product received by a particular user, "normally used" refers to a
303typical or common use of that class of product, regardless of the status
304of the particular user or of the way in which the particular user
305actually uses, or expects or is expected to use, the product. A product
306is a consumer product regardless of whether the product has substantial
307commercial, industrial or non-consumer uses, unless such uses represent
308the only significant mode of use of the product.
309
310 "Installation Information" for a User Product means any methods,
311procedures, authorization keys, or other information required to install
312and execute modified versions of a covered work in that User Product from
313a modified version of its Corresponding Source. The information must
314suffice to ensure that the continued functioning of the modified object
315code is in no case prevented or interfered with solely because
316modification has been made.
317
318 If you convey an object code work under this section in, or with, or
319specifically for use in, a User Product, and the conveying occurs as
320part of a transaction in which the right of possession and use of the
321User Product is transferred to the recipient in perpetuity or for a
322fixed term (regardless of how the transaction is characterized), the
323Corresponding Source conveyed under this section must be accompanied
324by the Installation Information. But this requirement does not apply
325if neither you nor any third party retains the ability to install
326modified object code on the User Product (for example, the work has
327been installed in ROM).
328
329 The requirement to provide Installation Information does not include a
330requirement to continue to provide support service, warranty, or updates
331for a work that has been modified or installed by the recipient, or for
332the User Product in which it has been modified or installed. Access to a
333network may be denied when the modification itself materially and
334adversely affects the operation of the network or violates the rules and
335protocols for communication across the network.
336
337 Corresponding Source conveyed, and Installation Information provided,
338in accord with this section must be in a format that is publicly
339documented (and with an implementation available to the public in
340source code form), and must require no special password or key for
341unpacking, reading or copying.
342
343 7. Additional Terms.
344
345 "Additional permissions" are terms that supplement the terms of this
346License by making exceptions from one or more of its conditions.
347Additional permissions that are applicable to the entire Program shall
348be treated as though they were included in this License, to the extent
349that they are valid under applicable law. If additional permissions
350apply only to part of the Program, that part may be used separately
351under those permissions, but the entire Program remains governed by
352this License without regard to the additional permissions.
353
354 When you convey a copy of a covered work, you may at your option
355remove any additional permissions from that copy, or from any part of
356it. (Additional permissions may be written to require their own
357removal in certain cases when you modify the work.) You may place
358additional permissions on material, added by you to a covered work,
359for which you have or can give appropriate copyright permission.
360
361 Notwithstanding any other provision of this License, for material you
362add to a covered work, you may (if authorized by the copyright holders of
363that material) supplement the terms of this License with terms:
364
365 a) Disclaiming warranty or limiting liability differently from the
366 terms of sections 15 and 16 of this License; or
367
368 b) Requiring preservation of specified reasonable legal notices or
369 author attributions in that material or in the Appropriate Legal
370 Notices displayed by works containing it; or
371
372 c) Prohibiting misrepresentation of the origin of that material, or
373 requiring that modified versions of such material be marked in
374 reasonable ways as different from the original version; or
375
376 d) Limiting the use for publicity purposes of names of licensors or
377 authors of the material; or
378
379 e) Declining to grant rights under trademark law for use of some
380 trade names, trademarks, or service marks; or
381
382 f) Requiring indemnification of licensors and authors of that
383 material by anyone who conveys the material (or modified versions of
384 it) with contractual assumptions of liability to the recipient, for
385 any liability that these contractual assumptions directly impose on
386 those licensors and authors.
387
388 All other non-permissive additional terms are considered "further
389restrictions" within the meaning of section 10. If the Program as you
390received it, or any part of it, contains a notice stating that it is
391governed by this License along with a term that is a further
392restriction, you may remove that term. If a license document contains
393a further restriction but permits relicensing or conveying under this
394License, you may add to a covered work material governed by the terms
395of that license document, provided that the further restriction does
396not survive such relicensing or conveying.
397
398 If you add terms to a covered work in accord with this section, you
399must place, in the relevant source files, a statement of the
400additional terms that apply to those files, or a notice indicating
401where to find the applicable terms.
402
403 Additional terms, permissive or non-permissive, may be stated in the
404form of a separately written license, or stated as exceptions;
405the above requirements apply either way.
406
407 8. Termination.
408
409 You may not propagate or modify a covered work except as expressly
410provided under this License. Any attempt otherwise to propagate or
411modify it is void, and will automatically terminate your rights under
412this License (including any patent licenses granted under the third
413paragraph of section 11).
414
415 However, if you cease all violation of this License, then your
416license from a particular copyright holder is reinstated (a)
417provisionally, unless and until the copyright holder explicitly and
418finally terminates your license, and (b) permanently, if the copyright
419holder fails to notify you of the violation by some reasonable means
420prior to 60 days after the cessation.
421
422 Moreover, your license from a particular copyright holder is
423reinstated permanently if the copyright holder notifies you of the
424violation by some reasonable means, this is the first time you have
425received notice of violation of this License (for any work) from that
426copyright holder, and you cure the violation prior to 30 days after
427your receipt of the notice.
428
429 Termination of your rights under this section does not terminate the
430licenses of parties who have received copies or rights from you under
431this License. If your rights have been terminated and not permanently
432reinstated, you do not qualify to receive new licenses for the same
433material under section 10.
434
435 9. Acceptance Not Required for Having Copies.
436
437 You are not required to accept this License in order to receive or
438run a copy of the Program. Ancillary propagation of a covered work
439occurring solely as a consequence of using peer-to-peer transmission
440to receive a copy likewise does not require acceptance. However,
441nothing other than this License grants you permission to propagate or
442modify any covered work. These actions infringe copyright if you do
443not accept this License. Therefore, by modifying or propagating a
444covered work, you indicate your acceptance of this License to do so.
445
446 10. Automatic Licensing of Downstream Recipients.
447
448 Each time you convey a covered work, the recipient automatically
449receives a license from the original licensors, to run, modify and
450propagate that work, subject to this License. You are not responsible
451for enforcing compliance by third parties with this License.
452
453 An "entity transaction" is a transaction transferring control of an
454organization, or substantially all assets of one, or subdividing an
455organization, or merging organizations. If propagation of a covered
456work results from an entity transaction, each party to that
457transaction who receives a copy of the work also receives whatever
458licenses to the work the party's predecessor in interest had or could
459give under the previous paragraph, plus a right to possession of the
460Corresponding Source of the work from the predecessor in interest, if
461the predecessor has it or can get it with reasonable efforts.
462
463 You may not impose any further restrictions on the exercise of the
464rights granted or affirmed under this License. For example, you may
465not impose a license fee, royalty, or other charge for exercise of
466rights granted under this License, and you may not initiate litigation
467(including a cross-claim or counterclaim in a lawsuit) alleging that
468any patent claim is infringed by making, using, selling, offering for
469sale, or importing the Program or any portion of it.
470
471 11. Patents.
472
473 A "contributor" is a copyright holder who authorizes use under this
474License of the Program or a work on which the Program is based. The
475work thus licensed is called the contributor's "contributor version".
476
477 A contributor's "essential patent claims" are all patent claims
478owned or controlled by the contributor, whether already acquired or
479hereafter acquired, that would be infringed by some manner, permitted
480by this License, of making, using, or selling its contributor version,
481but do not include claims that would be infringed only as a
482consequence of further modification of the contributor version. For
483purposes of this definition, "control" includes the right to grant
484patent sublicenses in a manner consistent with the requirements of
485this License.
486
487 Each contributor grants you a non-exclusive, worldwide, royalty-free
488patent license under the contributor's essential patent claims, to
489make, use, sell, offer for sale, import and otherwise run, modify and
490propagate the contents of its contributor version.
491
492 In the following three paragraphs, a "patent license" is any express
493agreement or commitment, however denominated, not to enforce a patent
494(such as an express permission to practice a patent or covenant not to
495sue for patent infringement). To "grant" such a patent license to a
496party means to make such an agreement or commitment not to enforce a
497patent against the party.
498
499 If you convey a covered work, knowingly relying on a patent license,
500and the Corresponding Source of the work is not available for anyone
501to copy, free of charge and under the terms of this License, through a
502publicly available network server or other readily accessible means,
503then you must either (1) cause the Corresponding Source to be so
504available, or (2) arrange to deprive yourself of the benefit of the
505patent license for this particular work, or (3) arrange, in a manner
506consistent with the requirements of this License, to extend the patent
507license to downstream recipients. "Knowingly relying" means you have
508actual knowledge that, but for the patent license, your conveying the
509covered work in a country, or your recipient's use of the covered work
510in a country, would infringe one or more identifiable patents in that
511country that you have reason to believe are valid.
512
513 If, pursuant to or in connection with a single transaction or
514arrangement, you convey, or propagate by procuring conveyance of, a
515covered work, and grant a patent license to some of the parties
516receiving the covered work authorizing them to use, propagate, modify
517or convey a specific copy of the covered work, then the patent license
518you grant is automatically extended to all recipients of the covered
519work and works based on it.
520
521 A patent license is "discriminatory" if it does not include within
522the scope of its coverage, prohibits the exercise of, or is
523conditioned on the non-exercise of one or more of the rights that are
524specifically granted under this License. You may not convey a covered
525work if you are a party to an arrangement with a third party that is
526in the business of distributing software, under which you make payment
527to the third party based on the extent of your activity of conveying
528the work, and under which the third party grants, to any of the
529parties who would receive the covered work from you, a discriminatory
530patent license (a) in connection with copies of the covered work
531conveyed by you (or copies made from those copies), or (b) primarily
532for and in connection with specific products or compilations that
533contain the covered work, unless you entered into that arrangement,
534or that patent license was granted, prior to 28 March 2007.
535
536 Nothing in this License shall be construed as excluding or limiting
537any implied license or other defenses to infringement that may
538otherwise be available to you under applicable patent law.
539
540 12. No Surrender of Others' Freedom.
541
542 If conditions are imposed on you (whether by court order, agreement or
543otherwise) that contradict the conditions of this License, they do not
544excuse you from the conditions of this License. If you cannot convey a
545covered work so as to satisfy simultaneously your obligations under this
546License and any other pertinent obligations, then as a consequence you may
547not convey it at all. For example, if you agree to terms that obligate you
548to collect a royalty for further conveying from those to whom you convey
549the Program, the only way you could satisfy both those terms and this
550License would be to refrain entirely from conveying the Program.
551
552 13. Use with the GNU Affero General Public License.
553
554 Notwithstanding any other provision of this License, you have
555permission to link or combine any covered work with a work licensed
556under version 3 of the GNU Affero General Public License into a single
557combined work, and to convey the resulting work. The terms of this
558License will continue to apply to the part which is the covered work,
559but the special requirements of the GNU Affero General Public License,
560section 13, concerning interaction through a network will apply to the
561combination as such.
562
563 14. Revised Versions of this License.
564
565 The Free Software Foundation may publish revised and/or new versions of
566the GNU General Public License from time to time. Such new versions will
567be similar in spirit to the present version, but may differ in detail to
568address new problems or concerns.
569
570 Each version is given a distinguishing version number. If the
571Program specifies that a certain numbered version of the GNU General
572Public License "or any later version" applies to it, you have the
573option of following the terms and conditions either of that numbered
574version or of any later version published by the Free Software
575Foundation. If the Program does not specify a version number of the
576GNU General Public License, you may choose any version ever published
577by the Free Software Foundation.
578
579 If the Program specifies that a proxy can decide which future
580versions of the GNU General Public License can be used, that proxy's
581public statement of acceptance of a version permanently authorizes you
582to choose that version for the Program.
583
584 Later license versions may give you additional or different
585permissions. However, no additional obligations are imposed on any
586author or copyright holder as a result of your choosing to follow a
587later version.
588
589 15. Disclaimer of Warranty.
590
591 THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599
600 16. Limitation of Liability.
601
602 IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610SUCH DAMAGES.
611
612 17. Interpretation of Sections 15 and 16.
613
614 If the disclaimer of warranty and limitation of liability provided
615above cannot be given local legal effect according to their terms,
616reviewing courts shall apply local law that most closely approximates
617an absolute waiver of all civil liability in connection with the
618Program, unless a warranty or assumption of liability accompanies a
619copy of the Program in return for a fee.
620
621 END OF TERMS AND CONDITIONS
622
623 How to Apply These Terms to Your New Programs
624
625 If you develop a new program, and you want it to be of the greatest
626possible use to the public, the best way to achieve this is to make it
627free software which everyone can redistribute and change under these terms.
628
629 To do so, attach the following notices to the program. It is safest
630to attach them to the start of each source file to most effectively
631state the exclusion of warranty; and each file should have at least
632the "copyright" line and a pointer to where the full notice is found.
633
634 <one line to give the program's name and a brief idea of what it does.>
635 Copyright (C) <year> <name of author>
636
637 This program is free software: you can redistribute it and/or modify
638 it under the terms of the GNU General Public License as published by
639 the Free Software Foundation, either version 3 of the License, or
640 (at your option) any later version.
641
642 This program is distributed in the hope that it will be useful,
643 but WITHOUT ANY WARRANTY; without even the implied warranty of
644 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 GNU General Public License for more details.
646
647 You should have received a copy of the GNU General Public License
648 along with this program. If not, see <http://www.gnu.org/licenses/>.
649
650Also add information on how to contact you by electronic and paper mail.
651
652 If the program does terminal interaction, make it output a short
653notice like this when it starts in an interactive mode:
654
655 <program> Copyright (C) <year> <name of author>
656 This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 This is free software, and you are welcome to redistribute it
658 under certain conditions; type `show c' for details.
659
660The hypothetical commands `show w' and `show c' should show the appropriate
661parts of the General Public License. Of course, your program's commands
662might be different; for a GUI interface, you would use an "about box".
663
664 You should also get your employer (if you work as a programmer) or school,
665if any, to sign a "copyright disclaimer" for the program, if necessary.
666For more information on this, and how to apply and follow the GNU GPL, see
667<http://www.gnu.org/licenses/>.
668
669 The GNU General Public License does not permit incorporating your program
670into proprietary programs. If your program is a subroutine library, you
671may consider it more useful to permit linking proprietary applications with
672the library. If this is what you want to do, use the GNU Lesser General
673Public License instead of this License. But first, please read
674<http://www.gnu.org/philosophy/why-not-lgpl.html>.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..5723c45
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,18 @@
1PREFIX = /usr/local
2BINDIR = $(PREFIX)/bin
3SRCDIR = $(PREFIX)/src
4JOCDIR = $(SRCDIR)/joc
5
6install:
7 mkdir -p $(DESTDIR)$(BINDIR)
8 mkdir -p $(DESTDIR)$(SRCDIR)
9 mkdir -p $(DESTDIR)$(JOCDIR)
10 cp -rf *.py jellyfin_apiclient_python/ $(DESTDIR)$(JOCDIR)
11 chmod 755 $(DESTDIR)$(JOCDIR)/joc.py
12 ln -s $(DESTDIR)$(JOCDIR)/joc.py $(DESTDIR)$(BINDIR)/joc
13
14uninstall:
15 rm -rf $(DESTDIR)$(JOCDIR)
16 rm -f $(DESTDIR)$(BINDIR)/joc
17
18.PHONY: install uninstall
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0184169
--- /dev/null
+++ b/README.md
@@ -0,0 +1,94 @@
1# joc - Jellyfin on Console
2joc is a lightweight terminal client to your Jellyfin media server. joc has
3a sleek curses interface and offers many of the same features as your other favorite
4terminal programs including: Vim-like keybindings, keystroke combination actions,
5user input bar, configurable color scheme, and more.
6
7## Installation
8
9### Dependencies
10
11Install the following dependencies. The following are the package names
12on Debian derivatives. If you are using a different distro the package names
13may be slightly different.
14
15- python3-mpv
16- python3-urwid
17- python3-urllib3
18- python3-certifi
19- python3-websocket
20- python3-requests
21- python3-six
22
23```
24git clone https://git.chudnick.com/joc
25cd joc
26sudo make install
27```
28
29## Configuration
30
31You'll need to do some basic configuration to get joc talking to your Jellyfin
32server. You can copy the example `config.ini` to the configuration directory at
33`~/.config/joc` to begin.
34
35At the minimum, you'll need to change the
36`server`, `username`, and `password` options to fit your environment. `server` is
37the url for your jellyfin server and `username` and `password` are the credentials
38for an account on the server. Instead of storing a plaintext password in the config
39file you may want to instead use the `passcmd` setting which allows you to specify
40a command to run to get the password instead.
41
42The `verify_tls` option determines whether joc will verify the Jellyfin server's
43certificate. `0` tells joc not to verify the certificate while `1` tells joc to
44verify and drop the connection if the certificate is invalid. If you are accessing
45a public Jellyfin instance it is highly recommended to set this to `1`, but for
46internal servers you will probably need to set this to `0`.
47
48## Usage
49
50Simply run `joc` from your terminal or application launcher after doing the basic
51configuration.
52
53## Modes
54
55Like Vim, joc contains a number of different modes
56
57- `Main` - Main mode is where you will spend most of your time. In this mode
58 the Jellyfin library is displayed as a folder structure and watched
59 items are highlighted.
60- `Favorites` - The same as main mode but favorites are highlighted instead.
61- `Search` - Search mode displays the results of a search. Search mode does not
62 have a folder structure like main and favorites mode.
63- `Info` - Shows additonal information about a selected item
64
65## Keybindings
66
67- `l` - Move into a folder
68- `j` - Scroll up through a list of items
69- `k` - Scroll down through a list of items
70- `h` - Move out of a folder and back up the folder structure
71- `g` - Go to the top item
72- `G` - Go to the bottom item
73- `Enter` - Play the currently highlighted item
74- `i` - View more information about an item
75- `:` - Enter input mode to provide a command
76- `/` - Search for a provided term (shorcut for `:search `)
77- `q` - Quit a non-standard mode
78- `Q` - Quit the program
79
80## Keystroke Combinations
81
82- `mw` - Mark item as watched
83- `uw` - Mark item as not watched
84- `mf` - Mark item as favorite
85- `uf` - Mark item as not a favorite
86- `sf` - Toggle favorites mode
87- `sm` - Toggle main mode
88
89## Limitations
90
91The Jellyfin API does not currently provide a method to update the playback progress
92for an item so as of now joc cannot sync the progress with the Jellyfin server. joc
93uses a local sqlite database to store this information instead. Other information
94user data such as watched status and favorites are synced with the server.
diff --git a/config.ini b/config.ini
new file mode 100644
index 0000000..4f8fd58
--- /dev/null
+++ b/config.ini
@@ -0,0 +1,32 @@
1[connection]
2server = https://jellyfin.home.local
3username = jellyfin
4password = changeme
5#passcmd = pass jellyfin
6verify_tls = 0
7
8[colors]
9header_fg = black
10header_bg = light gray
11text_fg = white
12text_bg = default
13focus_fg = white
14focus_bg = dark read
15text_viewed_fg = yellow
16text_viewed_bg = default
17focus_viewed_fg = yellow
18focus_viewed_bg = dark red
19fav_fg = light green
20fav_bg = default
21non_fav_fg = dark gray
22non_fav_bg = default
23fav_foc_fg = light green
24fav_foc_bg = dark gray
25non_fav_foc_fg = light gray
26non_fav_foc_bg = dark gray
27
28
29[options]
30autoplay = off
31search_insensitive = off
32
diff --git a/config.py b/config.py
new file mode 100644
index 0000000..184dbd8
--- /dev/null
+++ b/config.py
@@ -0,0 +1,21 @@
1"""
2Functions for handling the program's configuration file
3"""
4
5from configparser import ConfigParser
6import os
7
8CONFIG_FILE = os.path.expanduser("~/.config/joc/config.ini")
9
10def read_config_file():
11 """ Read config file and return ConfigParser instance """
12 parser = ConfigParser(inline_comment_prefixes="#")
13 parser.read(os.path.expanduser(CONFIG_FILE))
14 return parser
15
16
17def write_config_file(parser):
18 """ Write ConfigParser contents back to config file """
19 with open(os.path.expanduser(CONFIG_FILE), "w") as conf:
20 parser.write(conf)
21
diff --git a/database.py b/database.py
new file mode 100644
index 0000000..1c5304c
--- /dev/null
+++ b/database.py
@@ -0,0 +1,64 @@
1"""
2Defines a database class for holding locally stored information
3"""
4
5import sqlite3
6import os
7
8class Database(object):
9
10 def __init__(self, path):
11 self.path = path
12 if not self.exists():
13 self.create()
14
15 def exists(self):
16 """ Checks if the database path exists """
17 return os.path.exists(self.path)
18
19
20 def create(self):
21 with sqlite3.connect(self.path) as conn:
22 c = conn.cursor()
23 c.execute("""CREATE TABLE playstate (
24 item_id text,
25 pct_played float,
26 sec_played int
27 )""")
28
29
30 def query_playstate(self, item_id):
31 """ Gets the playstate data for a given item id """
32 with sqlite3.connect(self.path) as conn:
33 c = conn.cursor()
34 c.execute("SELECT * FROM playstate WHERE item_id=?",(item_id,))
35 playstate = c.fetchone()
36
37 if playstate == None:
38 playstate = ("",0,0)
39 return playstate
40
41
42 def update_playstate(self, item_id, pct_played, sec_played):
43 """ Updates the playstate of a given item id """
44 with sqlite3.connect(self.path) as conn:
45 c = conn.cursor()
46
47 # Delete item from DB if not needed to keep database small
48 if sec_played == 0:
49 c.execute("DELETE FROM playstate WHERE item_id=?",(item_id,))
50 with open("log","a") as log:
51 log.write("deleted " + item_id + " from playstate db\n")
52
53 else:
54 c.execute("SELECT * FROM playstate WHERE item_id=?",(item_id,))
55
56 # If item not in DB add it, otherwise update the entry
57 if c.fetchone() == None:
58 c.execute("INSERT INTO playstate VALUES (?,?,?)",
59 (item_id,pct_played,sec_played))
60 else:
61 c.execute("UPDATE playstate SET pct_played=? WHERE item_id=?",
62 (pct_played,item_id))
63 c.execute("UPDATE playstate SET sec_played=? WHERE item_id=?",
64 (sec_played,item_id))
diff --git a/interface.py b/interface.py
new file mode 100644
index 0000000..5d3ff24
--- /dev/null
+++ b/interface.py
@@ -0,0 +1,626 @@
1"""
2Defines a class to represent the user interface of the program.
3"""
4
5import config
6
7import sys
8import subprocess
9import threading
10import os
11import urwid
12import string
13import random
14import json
15import mpv
16import time
17
18class Interface:
19
20 def __init__(self, jellyfin, libs, parser, db):
21 self.jellyfin = jellyfin
22 self.parser = parser
23 self.db = db
24
25 # Focus
26 self.focus = 0
27 self.main_focus = 0
28 self.search_focus = 0
29
30 # Settings
31 self.show_favorites = False
32 self.autoplay = False
33
34 # Search
35 self.search_insensitive = True
36 self.search_term = None
37 self.search_mode = False
38 self.search_results = []
39 self.search_position = 0
40
41 # Information
42 self.info_mode = False
43
44 # Commands
45 self.input_buffer = []
46 self.cmd_pos = -1
47 self.cmd_history = []
48 #self.cmd_history = commands.read_cmdhist()
49
50 # Jellyfin
51 self.libs = libs # User media folders
52 self.display_contents = [] # JSON of items currently being displayed
53 self.focus_stack = [] # Keeps track of focus on various screens
54
55
56
57 def parse_options(self):
58 if not self.parser.has_section("options"):
59 return
60 autoplay = self.parser.get("options", "autoplay", fallback="off")
61 search_insensitive = self.parser.get("options","search_insensitive",
62 fallback="on")
63 if autoplay == "on":
64 self.autoplay = True
65 if search_insensitive == 'off':
66 self.search_insenstive = False
67
68
69 def get_colors(self):
70 valid_colors = [ 'black', 'brown', 'dark blue', 'dark cyan', 'dark gray',
71 'dark green', 'dark magenta', 'dark red', 'light blue',
72 'light cyan', 'light gray', 'light green', 'light magenta',
73 'light red', 'white', 'yellow', 'default']
74
75 # Validate and sanitize values
76 if self.parser.has_section("colors"):
77 for name, value in self.parser.items("colors"):
78 if value not in valid_colors:
79 self.parser.remove_option("colors", name)
80
81 # Get colors from config file
82
83 # Main view colors
84 header_fg = self.parser.get("colors", "header_fg", fallback="black")
85 header_bg = self.parser.get("colors", "header_bg", fallback="light gray")
86 text_fg = self.parser.get("colors", "text_fg", fallback="white")
87 text_bg = self.parser.get("colors", "text_bg", fallback="default")
88 focus_fg = self.parser.get("colors", "focus_fg", fallback="white")
89 focus_bg = self.parser.get("colors", "focus_bg", fallback="dark red")
90 text_viewed_fg = self.parser.get("colors", "text_viewed_fg", fallback="yellow")
91 text_viewed_bg = self.parser.get("colors", "text_viewed_bg",fallback="default")
92 focus_viewed_fg = self.parser.get("colors","focus_viewed_fg",fallback="yellow")
93 focus_viewed_bg=self.parser.get("colors","focus_viewed_bg",fallback="dark red")
94
95 # Favorites view colors
96 fav_fg = self.parser.get("colors", "fav_fg", fallback = "light green")
97 fav_bg = self.parser.get("colors", "fav_bg", fallback = "default")
98 non_fav_fg = self.parser.get("colors", "non_fav_fg", fallback = "dark gray")
99 non_fav_bg = self.parser.get("colors", "non_fav_bg", fallback = "default")
100 fav_foc_fg = self.parser.get("colors", "fav_foc_fg", fallback = "light green")
101 fav_foc_bg = self.parser.get("colors", "fav_foc_bg", fallback = "dark gray")
102 non_fav_foc_fg = self.parser.get("colors","non_fav_foc_fg",
103 fallback="light gray")
104 non_fav_foc_bg = self.parser.get("colors","non_fav_foc_bg",
105 fallback="dark gray")
106
107 self.palette = [
108 ('header', header_fg, header_bg),
109 ('text', text_fg, text_bg),
110 ('focus', focus_fg, focus_bg),
111 ('text_viewed', text_viewed_fg, text_viewed_bg),
112 ('focus_viewed', focus_viewed_fg, focus_viewed_bg),
113 ('fav', fav_fg, fav_bg),
114 ('fav-foc', fav_foc_fg, fav_foc_bg),
115 ('non-fav', non_fav_fg, non_fav_bg),
116 ('non-fav-foc', non_fav_foc_fg, non_fav_foc_bg),
117 ('error', 'dark red', 'black'),
118 ]
119
120
121 def start(self):
122 """ Start curses and initialize containers, windows, and variables """
123 self.screen = urwid.raw_display.Screen()
124 self.get_colors()
125 self.parse_options()
126 self.screen.register_palette(self.palette)
127 self.build()
128 self.screen.run_wrapper(self.run)
129
130
131 def build(self):
132 """ Build urwid/curses UI """
133 self.header = urwid.AttrWrap(urwid.Text("joc"), "header")
134 self.content = urwid.SimpleFocusListWalker([])
135 self.body = urwid.ListBox(self.content)
136 self.display_contents = self.libs
137 self.display(self.libs)
138 self.input = urwid.Edit("")
139 self.ui_main = urwid.Frame(self.body, header=self.header, footer=self.input)
140 self.ui_main.set_focus("body")
141
142
143 def run(self):
144 """ Run urwid UI until exit """
145 def input_handler(key):
146 self.keypress(key)
147
148 self.size = self.screen.get_cols_rows()
149 self.mainloop = urwid.MainLoop(
150 self.ui_main,
151 screen=self.screen,
152 handle_mouse=False,
153 unhandled_input=input_handler
154 )
155 try:
156 self.mainloop.run()
157 except KeyboardInterrupt:
158 self.end()
159
160
161 def end(self):
162 """ Exit gracefully when user ends program """
163 sys.exit(0)
164
165
166 def keypress(self, key):
167 """ Handle a key press by the user """
168 if self.ui_main.get_focus() == "body":
169 if key == 'h' or key == 'left':
170 if not self.search_mode:
171 self.shift_left()
172 self.clear_buffer()
173 elif key == 'enter':
174 result = self.play()
175 self.clear_buffer()
176 elif key == 'j' or key == 'down':
177 self.scroll_down()
178 self.clear_buffer()
179 elif key =='k' or key == 'up':
180 self.scroll_up()
181 self.clear_buffer()
182 elif key == 'g':
183 self.focus = 0
184 self.content.set_focus(self.focus)
185 self.clear_buffer()
186 elif key == 'G':
187 self.focus = len(self.content) - 1
188 self.content.set_focus(self.focus)
189 self.clear_buffer()
190 elif key == 'l'or key == 'right':
191 if not self.search_mode:
192 self.shift_right()
193 self.clear_buffer()
194 elif key == 'i':
195 self.info_mode = True
196 if not self.search_mode:
197 self.main_focus = self.focus
198 elif self.search_mode:
199 self.search_focus = self.focus
200 self.header.set_text("Info Mode")
201 self.get_info()
202 elif key == 'q':
203 if self.search_mode and not self.info_mode:
204 self.search_mode = False
205 self.search_term = None
206 self.search_results = []
207 self.header.set_text("Main Mode")
208 self.focus = self.main_focus
209 self.display(self.display_contents)
210 elif self.search_mode and self.info_mode:
211 self.info_mode = False
212 self.focus = self.search_focus
213 self.header.set_text("Search Mode")
214 self.display(self.search_results)
215 elif self.info_mode and not self.search_mode:
216 self.info_mode = False
217 self.focus = self.main_focus
218 self.header.set_text("Main Mode")
219 self.display(self.display_contents)
220 elif key == 'Q':
221 self.end()
222 elif key == ':':
223 self.clear_buffer()
224 self.ui_main.set_focus("footer")
225 self.input.set_caption(":")
226 elif key == "/":
227 self.clear_buffer()
228 self.ui_main.set_focus("footer")
229 self.input.set_caption(":")
230 self.input.insert_text("search ")
231 elif key == "esc":
232 self.clear_buffer()
233 else:
234 self.input_buffer.append(key)
235 self.parse_buffer()
236
237 elif self.ui_main.get_focus() == "footer":
238 if key == 'esc':
239 self.ui_main.set_focus("body")
240 self.input.set_edit_text("")
241 self.input.set_caption("")
242 self.cmd_pos = -1
243 elif key == 'enter':
244 self.parse_input()
245 elif key == 'up':
246 if self.cmd_pos == -1 and self.input.get_edit_text() != "":
247 pass
248 elif self.cmd_pos+1 < len(self.cmd_history):
249 self.cmd_pos += 1
250 self.input.set_edit_text("")
251 self.input.insert_text(self.cmd_history[self.cmd_pos])
252 elif key == 'down':
253 if self.cmd_pos-1 > -1:
254 self.cmd_pos -= 1
255 self.input.set_edit_text("")
256 self.input.insert_text(self.cmd_history[self.cmd_pos])
257 elif self.cmd_pos-1 == -1:
258 self.cmd_pos -= 1
259 self.input.set_edit_text("")
260
261
262 def clear_buffer(self):
263 """ Reset keystroke combination buffer """
264 self.input_buffer = []
265 self.input.set_caption("")
266
267
268 def parse_buffer(self):
269 """Check the input buffer to see if the user has entered an action
270 Perform the associated action if applicable
271
272 Keystroke combination actions consist of two parts:
273 action - the first key in the combination, tells what to do (mark, show)
274 argument - tells what to do with the action. actions generally have more than
275 one argument, for example, watched and favorite are both valid
276 arguments to the mark action. arguments are also generally
277 adjectives, used to describe a media item, again such as favorite
278 or watched."""
279
280 keys = ''.join(self.input_buffer)
281 if self.search_mode:
282 focus_item = self.search_results[self.focus]
283 else:
284 focus_item = self.display_contents[self.focus]
285
286 if keys == 'mw':
287 # Mark watched
288 self.jellyfin.mark_watched(focus_item["Id"])
289 self.update_display()
290 self.clear_buffer()
291
292 elif keys == 'uw':
293 # Unmark watched
294 self.jellyfin.mark_watched(focus_item["Id"], False)
295 self.update_display()
296 self.clear_buffer()
297
298 elif keys == 'mf':
299 # Mark favorite
300 self.jellyfin.mark_favorite(focus_item["Id"])
301 self.update_display()
302 self.clear_buffer()
303
304 elif keys == "uf":
305 # Unmark favorite
306 self.jellyfin.mark_favorite(focus_item["Id"], False)
307 self.update_display()
308 self.clear_buffer()
309
310 elif keys == "sf":
311 # Show favorites
312 self.show_favorites = True
313 self.header.set_text("Favorites View")
314 self.update_display()
315 self.clear_buffer()
316
317 elif keys == 'sm':
318 # Show main
319 self.show_favorites = False
320 self.header.set_text("Main View")
321 self.update_display()
322 self.clear_buffer()
323
324 else:
325 action_keys = ['m', 'u', 's']
326 arguments = {'m':['f','w'], 'u':['f','w'], 's':['f', 'm']}
327 if len(keys) == 1 and keys[0] not in action_keys:
328 # If there is only one key in the buffer, and it is not a valid action
329 # key, clear the buffer
330 self.clear_buffer()
331 elif len(keys) > 1 and keys[1] not in arguments[keys[0]]:
332 # If there are multiple (currently shouldn't be greater than 2) keys in
333 # the buffer, and the second isn't a valid argument to the action key,
334 # clear the buffer
335 self.clear_buffer()
336 else:
337 # Will only be triggered if there is one key in the buffer, and it is
338 # a valid action key. In which case update buffer display for user
339 self.input.set_caption(keys)
340
341
342 def parse_input(self):
343 """ Parse the command input and perform the associated action
344 if the command is valid """
345 command = self.input.get_edit_text().strip()
346 # History
347 if len(self.cmd_history) == 0 or self.cmd_history[0] != command:
348 with open(os.path.expanduser("~/.config/joc/history"), "a") as hist:
349 hist.write(command + "\n")
350 self.cmd_history = []
351
352 with open(os.path.expanduser("~/.config/joc/history")) as hist:
353 for line in hist:
354 if not line.startswith("#"):
355 self.cmd_history.append(line.strip())
356
357 self.cmd_history.reverse()
358
359 # Parse input to get command
360 keyword = command.split(' ')[0]
361 if keyword == "search":
362 searchterm = ''.join(command.split(' ')[1:])
363 results = self.jellyfin.search(searchterm)
364 if len(results) == 0:
365 self.input.set_caption("No results found")
366 else:
367 self.search_mode = True
368 self.main_focus = self.focus
369 self.search_term = searchterm
370 self.search_results = results
371 self.header.set_text("Search Mode")
372 self.focus = 0
373 self.display(results)
374
375 elif keyword.startswith("clearhist"):
376 commands.clear_cmdhist()
377 self.cmd_history = commands.read_cmdhist()
378
379 elif keyword.startswith("reset-viewed"):
380 command = command.split(' ')
381
382 elif keyword.startswith("set-viewed"):
383 command = command.split(' ')
384
385 elif keyword == "autoplay":
386 if self.autoplay == False:
387 self.autoplay = True
388 self.input.set_caption("Autoplay enabled")
389 else:
390 self.autoplay = False
391 self.input.set_caption("Autoplay disabled")
392
393 elif keyword == "autoplay-status":
394 if self.autoplay == True:
395 self.input.set_caption("Autoplay is enabled")
396 else:
397 self.input.set_caption("Autoplay is disabled")
398
399 elif keyword == 'search-insensitive':
400 self.search_insensitive = True
401
402 elif keyword == 'search-sensitive':
403 self.search_insensitive = False
404
405 # Reset input bar
406 self.input.set_edit_text("")
407 # Reset the caption unless it is being used for a message
408 if self.input.caption == ":":
409 self.input.set_caption("")
410 self.ui_main.set_focus("body")
411 self.cmd_pos = -1
412
413
414
415 def display(self, content:list):
416 """ Set given contents as current display objects and display them """
417
418 del self.content[:]
419
420 # Do not update display_contents if we are in search or info mode
421 if not self.search_mode and not self.info_mode:
422 self.display_contents = content
423
424 if self.info_mode:
425 for line in content:
426 self.content.append(urwid.AttrWrap(
427 urwid.Text(line), 'text','focus'))
428 self.content.set_focus(0)
429 return
430
431 for item in content:
432 title = item["Name"]
433
434 if self.show_favorites:
435 if item["UserData"]["IsFavorite"]:
436 self.content.append(urwid.AttrWrap(
437 urwid.Text(title), 'fav', 'fav-foc')
438 )
439 else:
440 self.content.append(urwid.AttrWrap(
441 urwid.Text(title), 'non-fav', 'non-fav-foc')
442 )
443 else:
444 if item["UserData"]["Played"] and item["Type"] != "Audio":
445 self.content.append(urwid.AttrWrap(
446 urwid.Text(title), 'text_viewed', 'focus_viewed')
447 )
448 else:
449 self.content.append(urwid.AttrWrap(
450 urwid.Text(title), 'text', 'focus')
451 )
452
453 self.content.set_focus(self.focus)
454
455
456 def update_display(self):
457 """ Refresh the display by redisplaying the current contents
458 Re-retrieves the objects to get any changes that were made"""
459
460 if self.search_mode:
461 results = self.jellyfin.search(self.search_term)
462 self.display(results)
463 else:
464 focus_item = self.display_contents[self.focus]
465 parent = self.jellyfin.get_parent(focus_item["Id"])
466 content = self.jellyfin.get_children(parent["Id"])
467 self.display(content)
468 self.content.set_focus(self.focus)
469
470
471 def is_media(self, item):
472 """ Determines if an item is a piece of media (as opposed to a container)
473 and returns a boolean. Determination is made by 'Type' field in Jellyfin
474 item JSON. """
475
476 media_types = ["Audio","AudioBook","Book","Episode","Movie","LiveTvProgram",
477 "MusicVideo","Photo","Program","TvProgram","Video"]
478 if item["Type"] in media_types:
479 return True
480 else:
481 return False
482
483
484 def shift_left(self):
485 """ Shifts the displayed contents 'left', can also be thought of as
486 moving 'up' in the folder structure of the media.
487 Moves from current contents to parent and parent's siblings """
488
489 # Do nothing if at media folders root level
490 if self.display_contents == self.libs:
491 return
492 else:
493 focus_item = self.display_contents[self.focus]
494 new_contents = self.jellyfin.get_previous(focus_item["Id"])
495 if len(self.focus_stack) == 0:
496 self.focus = 0
497 else:
498 self.focus = self.focus_stack.pop()
499 self.display(new_contents)
500
501
502 def shift_right(self):
503 """ Shift the display contents to the right by display the child items
504 of the currently selected item """
505
506 # Do nothing if item is media
507 focus_item = self.display_contents[self.focus]
508 if self.is_media(focus_item):
509 return
510 else:
511 new_contents = self.jellyfin.get_children(focus_item["Id"])
512 self.focus_stack.append(self.focus)
513 self.focus = 0
514 self.display(new_contents)
515
516
517 def scroll_up(self):
518 """ Scroll up in the list of currently displayed items """
519
520 if self.focus == 0:
521 return
522 self.focus -= 1
523 self.content.set_focus(self.focus)
524
525 def scroll_down(self):
526 """ Scroll down in the list of currently displayed items """
527
528 if self.focus == len(self.content)-1:
529 return
530 self.focus += 1
531 self.content.set_focus(self.focus)
532
533
534 def play(self):
535 """ Play the currently selected item """
536
537 if self.search_mode:
538 all_items = self.search_results[self.focus:]
539 focus_item = self.search_results[self.focus]
540 else:
541 all_items = self.display_contents[self.focus:]
542 focus_item = self.display_contents[self.focus]
543
544 player = mpv.MPV(ytdl=True,input_default_bindings=True,input_vo_keyboard=True)
545
546 @player.on_key_press('q')
547 def getpos_then_quit():
548 player.pct_played = player.percent_pos
549 player.sec_played = player.time_pos
550 player.quit()
551
552 try:
553 if self.autoplay:
554 AUTOPLAY_LIMIT = 10
555 autoplay_items = all_items[:AUTOPLAY_LIMIT]
556 current_item = focus_item
557
558 for item in autoplay_items:
559 url = self.jellyfin.get_url(item["Id"])
560 player.playlist_append(url)
561
562 player.playlist_pos = 0
563 count = 0
564 while count < AUTOPLAY_LIMIT:
565 current_item = autoplay_items[count]
566 pct_played, sec_played = self.get_playstate(focus_item["Id"])
567 player.wait_until_playing()
568 player.seek(float(pct_played), reference='absolute-percent')
569 player.wait_for_playback()
570 self.jellyfin.mark_watched(focus_item["Id"])
571 self.set_playstate(focus_item["Id"], 100.0, 0)
572 count += 1
573 self.focus += 1
574 else:
575 url = self.jellyfin.get_url(focus_item["Id"])
576 pct_played, sec_played = self.get_playstate(focus_item["Id"])
577 player.play(url)
578 player.wait_until_playing()
579 player.seek(float(pct_played), reference='absolute-percent')
580 player.wait_for_playback()
581 self.jellyfin.mark_watched(focus_item["Id"])
582 self.set_playstate(focus_item["Id"], 100.0, 0)
583
584 except mpv.ShutdownError:
585 if self.autoplay:
586 item_id = current_item["Id"]
587 else:
588 item_id = focus_item["Id"]
589 self.set_playstate(item_id,player.pct_played,player.sec_played)
590
591 player.terminate()
592 self.update_display()
593
594
595
596 def get_playstate(self, item_id):
597 DB_ITEMID_INDEX = 0
598 DB_PCT_INDEX = 1
599 DB_SEC_INDEX = 2
600 """ Gets playstate from local db
601 Returns (pct_played, sec_played) tuple """
602 playstate = self.db.query_playstate(item_id)
603 return playstate[DB_PCT_INDEX],playstate[DB_SEC_INDEX]
604
605 def set_playstate(self, item_id, pct_played, sec_played):
606 """ Updates playstate in local db of an item """
607 pct_played = float(pct_played)
608 sec_played = int(sec_played)
609 if pct_played > 95.0:
610 self.jellyfin.mark_watched(item_id)
611 self.db.update_playstate(item_id, 0, 0)
612 else:
613 self.jellyfin.mark_watched(item_id, False)
614 self.db.update_playstate(item_id, pct_played, sec_played)
615
616
617 def get_info(self):
618 """ Get and then display the JSON info of the currently selected item """
619
620 if self.search_mode:
621 focus_item = self.search_results[self.focus]
622 else:
623 focus_item = self.display_contents[self.focus]
624
625 info = self.jellyfin.get_info(focus_item["Id"])
626 self.display(info)
diff --git a/jellyfin.py b/jellyfin.py
new file mode 100644
index 0000000..007dccf
--- /dev/null
+++ b/jellyfin.py
@@ -0,0 +1,203 @@
1#!/usr/bin/env python3
2
3"""
4Class to manage Jellyfin server connection and REST API calls
5"""
6
7import jellyfin_apiclient_python as jellyfin
8import json
9import os
10import sys
11import warnings
12import threading
13import subprocess
14import ssl
15import shlex
16
17APP_NAME = "joc"
18CLIENT_VERSION = "0.01"
19DEVICE_NAME = "joc"
20USER_AGENT = "joc/0.01"
21DEVICE_ID = "joc"
22
23NO_VERIFY = 0
24VERIFY = 1
25
26SORT_ALNUM = 0
27SORT_DATE = 1
28
29MEDIA_TYPES = ["Audio","AudioBook","Book","Episode","Movie","LiveTvProgram",
30 "MusicVideo","Photo","Program","TvProgram","Video"]
31
32CONNECTION_STATE = jellyfin.connection_manager.CONNECTION_STATE
33
34
35def die(msg):
36 print(msg)
37 sys.exit(1)
38
39class JellyfinConnection(object):
40
41 def __init__(self, parser):
42
43 self.parser = parser
44
45 self.client = None
46 self.http_client = None
47 self.api = None
48 self.verify_tls = None
49
50 self.server = None
51 self.username = None
52 self.password = None
53 self.passcmd = None
54
55 self.read_config()
56 self.connect()
57
58 def read_config(self):
59 """ Read connection information from configuratino file """
60
61 if not self.parser.has_section("connection"):
62 die("error: unable to find connection info")
63
64 if not self.parser.has_option("connection","server"):
65 die("error: no server given")
66 if not self.parser.has_option("connection","username"):
67 die("error: no username given")
68 if not self.parser.has_option("connection","password") and \
69 not self.parser.has_option("connection","passcmd"):
70 die("error: no password or passcmd given")
71
72 self.server = self.parser.get("connection", "server")
73 self.username = self.parser.get("connection", "username")
74 self.password = self.parser.get("connection", "password", fallback=None)
75 self.passcmd = self.parser.get("connection", "passcmd", fallback=None)
76 verify_tls = self.parser.get("connection", "verify_tls", fallback=VERIFY)
77 try:
78 if int(verify_tls) not in [0,1]:
79 self.verify_tls = VERIFY
80 else:
81 self.verify_tls = int(verify_tls)
82 except ValueError:
83 self.verify_tls = VERIFY
84
85
86 # Taken mostly from jellyfin-mpv-shim
87 def client_factory(self):
88 """ Build and return JellyfinClient instance """
89
90 client = jellyfin.client.JellyfinClient()
91 client.config.data["app.default"] = True
92 client.config.app(APP_NAME,CLIENT_VERSION,DEVICE_NAME,DEVICE_ID)
93 client.config.data["http.user_agent"] = USER_AGENT
94 client.config.data["auth.ssl"] = self.verify_tls
95 if self.verify_tls == NO_VERIFY:
96 warnings.filterwarnings("ignore")
97 return client
98
99 def login(self, server, username, password):
100 """ Login to Jellyfin server with JellyfinClient instance """
101
102 client = self.client_factory()
103 result = client.auth.connect_to_address(server)
104 if result["State"] == CONNECTION_STATE["Unavailable"]:
105 die("error: unable to connect to server")
106 result = client.auth.login(server,username,password)
107 return client
108
109
110 def connect(self):
111 """ Initalizes JellyfinClient, HTTP connection, and API instance. """
112
113 # Prep Connection
114 if self.password == None:
115 password = subprocess.run(self.passcmd, shell=True, \
116 capture_output=True,text=True).stdout
117 self.password = password.strip()
118 self.client = self.login(self.server,self.username,self.password)
119 self.http_client = jellyfin.http.HTTP(self.client)
120 self.api = jellyfin.api.API(self.http_client)
121
122
123 def get_libraries(self):
124 """ Get user root media folders """
125
126 folders = self.api.get_media_folders()["Items"]
127 return sorted(folders,key=lambda folder: folder["Name"])
128
129 def get_children(self,parent_id):
130 """ Get children of a given parent_id.
131 Sorts by date created if items are videos or by name otherwise.
132 Returns the sotrted list """
133
134 children = self.api.get_items_by_letter(parent_id=parent_id, recurse=False)
135 children = children["Items"]
136
137 # Sort items, by date if video, by name otherwise
138 if len(children) > 0 and children[0]["Type"] == "MusicVideo":
139 children = sorted(children,key=lambda item: item["DateCreated"],
140 reverse=True)
141 else:
142 children = sorted(children,key=lambda item: item["Name"])
143
144 return children
145
146 def get_previous(self,item_id):
147 """Gets items that were shown on the previous screen, i.e.
148 the parent item's siblings, i.e. the children of the grandparent item """
149
150 PARENT_INDEX = 0
151 GRANDPARENT_INDEX = 1
152 ancestors = self.api.get_ancestors(item_id)
153
154 if len(ancestors) == 1 and ancestors[0]["Type"] == "UserRootFolder":
155 return self.get_libraries()
156 else:
157 grandparent = ancestors[GRANDPARENT_INDEX]
158 return self.get_children(grandparent["Id"])
159
160
161 def get_parent(self,item_id):
162 """ Get parent (first ancestor) of a given item """
163
164 PARENT_INDEX = 0
165 ancestors = self.api.get_ancestors(item_id)
166 parent = ancestors[PARENT_INDEX]
167 return parent
168
169 def search(self, term):
170 """ Search for a given term and return a alphabetically sorted list """
171
172 items = self.api.search_media_items(term,MEDIA_TYPES,None)["Items"]
173 items = sorted(items,key=lambda item: item["Name"])
174 return items
175
176 def get_info(self, item_id):
177 """ Get stored information about an item and return a pretty printed JSON
178 string representation of the item """
179
180 item = self.api.get_item(item_id)
181 return json.dumps(item,indent=4)
182
183 def mark_watched(self, item_id, watched=True):
184 """ Mark an item as watched or unwatched"""
185 self.api.item_played(item_id, watched)
186
187 def mark_favorite(self, item_id, favorite=True):
188 """ Mark an item as a favorite or unfavorite the item"""
189 self.api.favorite(item_id, favorite)
190
191 def get_url(self, item_id):
192 """ Get download URL of the selected media item """
193 url = self.api.download_url(item_id)
194 return url
195
196 def set_played_progress(self, item:dict, ticks):
197 item["UserData"]["PlaybackPositionTicks"] = int(ticks)
198 res = self.api.session_progress(item)
199
200
201if __name__ == '__main__':
202 main()
203
diff --git a/jellyfin_apiclient_python/__init__.py b/jellyfin_apiclient_python/__init__.py
new file mode 100644
index 0000000..dcc660f
--- /dev/null
+++ b/jellyfin_apiclient_python/__init__.py
@@ -0,0 +1,133 @@
1# -*- coding: utf-8 -*-
2from __future__ import division, absolute_import, print_function, unicode_literals
3
4#################################################################################################
5
6import logging
7
8from .client import JellyfinClient
9
10#################################################################################################
11
12
13class NullHandler(logging.Handler):
14 def emit(self, record):
15 print(self.format(record))
16
17
18loghandler = NullHandler
19LOG = logging.getLogger('Jellyfin')
20
21#################################################################################################
22
23
24def config(level=logging.INFO):
25
26 logger = logging.getLogger('Jellyfin')
27 logger.addHandler(Jellyfin.loghandler())
28 logger.setLevel(level)
29
30def has_attribute(obj, name):
31 try:
32 object.__getattribute__(obj, name)
33 return True
34 except AttributeError:
35 return False
36
37def ensure_client():
38
39 def decorator(func):
40 def wrapper(self, *args, **kwargs):
41
42 if self.client.get(self.server_id) is None:
43 self.construct()
44
45 return func(self, *args, **kwargs)
46
47 return wrapper
48 return decorator
49
50
51class Jellyfin(object):
52
53 ''' This is your Jellyfinclient, you can create more than one. The server_id is only a temporary thing
54 to communicate with the JellyfinClient().
55
56 from jellyfin import Jellyfin
57
58 Jellyfin('123456').config.data['app']
59
60 # Permanent client reference
61 client = Jellyfin('123456').get_client()
62 client.config.data['app']
63 '''
64
65 # Borg - multiple instances, shared state
66 _shared_state = {}
67 client = {}
68 server_id = "default"
69 loghandler = loghandler
70
71 def __init__(self, server_id=None):
72 self.__dict__ = self._shared_state
73 self.server_id = server_id or "default"
74
75 def get_client(self):
76 return self.client[self.server_id]
77
78 @classmethod
79 def set_loghandler(cls, func=loghandler, level=logging.INFO):
80
81 for handler in logging.getLogger('Jellyfin').handlers:
82 if isinstance(handler, cls.loghandler):
83 logging.getLogger('Jellyfin').removeHandler(handler)
84
85 cls.loghandler = func
86 config(level)
87
88 def close(self):
89
90 if self.server_id not in self.client:
91 return
92
93 self.client[self.server_id].stop()
94 self.client.pop(self.server_id, None)
95
96 LOG.info("---[ STOPPED JELLYFINCLIENT: %s ]---", self.server_id)
97
98 @classmethod
99 def close_all(cls):
100
101 for client in cls.client:
102 cls.client[client].stop()
103
104 cls.client = {}
105 LOG.info("---[ STOPPED ALL JELLYFINCLIENTS ]---")
106
107 @classmethod
108 def get_active_clients(cls):
109 return cls.client
110
111 @ensure_client()
112 def __setattr__(self, name, value):
113
114 if has_attribute(self, name):
115 return super(Jellyfin, self).__setattr__(name, value)
116
117 setattr(self.client[self.server_id], name, value)
118
119 @ensure_client()
120 def __getattr__(self, name):
121 return getattr(self.client[self.server_id], name)
122
123 def construct(self):
124
125 self.client[self.server_id] = JellyfinClient()
126
127 if self.server_id == 'default':
128 LOG.info("---[ START JELLYFINCLIENT ]---")
129 else:
130 LOG.info("---[ START JELLYFINCLIENT: %s ]---", self.server_id)
131
132
133config()
diff --git a/jellyfin_apiclient_python/api.py b/jellyfin_apiclient_python/api.py
new file mode 100644
index 0000000..a6df708
--- /dev/null
+++ b/jellyfin_apiclient_python/api.py
@@ -0,0 +1,653 @@
1# -*- coding: utf-8 -*-
2from __future__ import division, absolute_import, print_function, unicode_literals
3from datetime import datetime
4import requests
5import json
6import logging
7
8LOG = logging.getLogger('JELLYFIN.' + __name__)
9
10
11def jellyfin_url(client, handler):
12 return "%s/%s" % (client.config.data['auth.server'], handler)
13
14
15def basic_info():
16 return "Etag"
17
18
19def info():
20 return (
21 "Path,Genres,SortName,Studios,Writer,Taglines,LocalTrailerCount,"
22 "OfficialRating,CumulativeRunTimeTicks,ItemCounts,"
23 "Metascore,AirTime,DateCreated,People,Overview,"
24 "CriticRating,CriticRatingSummary,Etag,ShortOverview,ProductionLocations,"
25 "Tags,ProviderIds,ParentId,RemoteTrailers,SpecialEpisodeNumbers,"
26 "MediaSources,VoteCount,RecursiveItemCount,PrimaryImageAspectRatio"
27 )
28
29
30def music_info():
31 return (
32 "Etag,Genres,SortName,Studios,Writer,"
33 "OfficialRating,CumulativeRunTimeTicks,Metascore,"
34 "AirTime,DateCreated,MediaStreams,People,ProviderIds,Overview,ItemCounts"
35 )
36
37
38class API(object):
39
40 ''' All the api calls to the server.
41 '''
42 def __init__(self, client, *args, **kwargs):
43 self.client = client
44 self.config = client.config
45 self.default_timeout = 5
46
47 def _http(self, action, url, request={}):
48 request.update({'type': action, 'handler': url})
49
50 return self.client.request(request)
51
52 def _http_url(self, action, url, request={}):
53 request.update({"type": action, "handler": url})
54
55 return self.client.request_url(request)
56
57 def _http_stream(self, action, url, dest_file, request={}):
58 request.update({'type': action, 'handler': url})
59
60 self.client.request(request, dest_file=dest_file)
61
62 def _get(self, handler, params=None):
63 return self._http("GET", handler, {'params': params})
64
65 def _get_url(self, handler, params=None):
66 return self._http_url("GET", handler, {"params": params})
67
68 def _post(self, handler, json=None, params=None):
69 return self._http("POST", handler, {'params': params, 'json': json})
70
71 def _delete(self, handler, params=None):
72 return self._http("DELETE", handler, {'params': params})
73
74 def _get_stream(self, handler, dest_file, params=None):
75 self._http_stream("GET", handler, dest_file, {'params': params})
76
77 #################################################################################################
78
79 # Bigger section of the Jellyfin api
80
81 #################################################################################################
82
83 def try_server(self):
84 return self._get("System/Info/Public")
85
86 def sessions(self, handler="", action="GET", params=None, json=None):
87 if action == "POST":
88 return self._post("Sessions%s" % handler, json, params)
89 elif action == "DELETE":
90 return self._delete("Sessions%s" % handler, params)
91 else:
92 return self._get("Sessions%s" % handler, params)
93
94 def users(self, handler="", action="GET", params=None, json=None):
95 if action == "POST":
96 return self._post("Users/{UserId}%s" % handler, json, params)
97 elif action == "DELETE":
98 return self._delete("Users/{UserId}%s" % handler, params)
99 else:
100 return self._get("Users/{UserId}%s" % handler, params)
101
102 def items(self, handler="", action="GET", params=None, json=None):
103 if action == "POST":
104 return self._post("Items%s" % handler, json, params)
105 elif action == "DELETE":
106 return self._delete("Items%s" % handler, params)
107 else:
108 return self._get("Items%s" % handler, params)
109
110 def user_items(self, handler="", params=None):
111 return self.users("/Items%s" % handler, params=params)
112
113 def shows(self, handler, params):
114 return self._get("Shows%s" % handler, params)
115
116 def videos(self, handler):
117 return self._get("Videos%s" % handler)
118
119 def artwork(self, item_id, art, max_width, ext="jpg", index=None):
120 params = {"MaxWidth": max_width, "format": ext}
121 handler = ("Items/%s/Images/%s" % (item_id, art) if index is None
122 else "Items/%s/Images/%s/%s" % (item_id, art, index)
123 )
124
125 return self._get_url(handler, params)
126
127 def audio_url(self, item_id, container=None, audio_codec=None, max_streaming_bitrate=140000000):
128 params = {
129 "UserId": "{UserId}",
130 "DeviceId": "{DeviceId}",
131 "MaxStreamingBitrate": max_streaming_bitrate,
132 }
133
134 if container:
135 params["Container"] = container
136
137 if audio_codec:
138 params["AudioCodec"] = audio_codec
139
140 return self._get_url("Audio/%s/universal" % item_id, params)
141
142 def video_url(self, item_id, media_source_id=None):
143 params = {
144 "static": "true",
145 "DeviceId": "{DeviceId}"
146 }
147 if media_source_id is not None:
148 params["MediaSourceId"] = media_source_id
149
150 return self._get_url("Videos/%s/stream" % item_id, params)
151
152 def download_url(self, item_id):
153 params = {}
154 return self._get_url("Items/%s/Download" % item_id, params)
155
156 #################################################################################################
157
158 # More granular api
159
160 #################################################################################################
161
162 def get_users(self):
163 return self._get("Users")
164
165 def get_public_users(self):
166 return self._get("Users/Public")
167
168 def get_user(self, user_id=None):
169 return self.users() if user_id is None else self._get("Users/%s" % user_id)
170
171 def get_user_settings(self, client="emby"):
172 return self._get("DisplayPreferences/usersettings", params={
173 "userId": "{UserId}",
174 "client": client
175 })
176
177 def get_views(self):
178 return self.users("/Views")
179
180 def get_media_folders(self):
181 return self.users("/Items")
182
183 def get_item(self, item_id):
184 return self.users("/Items/%s" % item_id)
185
186 def get_items(self, item_ids):
187 return self.users("/Items", params={
188 'Ids': ','.join(str(x) for x in item_ids),
189 'Fields': info()
190 })
191
192 def get_sessions(self):
193 return self.sessions(params={'ControllableByUserId': "{UserId}"})
194
195 def get_device(self, device_id):
196 return self.sessions(params={'DeviceId': device_id})
197
198 def post_session(self, session_id, url, params=None, data=None):
199 return self.sessions("/%s/%s" % (session_id, url), "POST", params, data)
200
201 def get_images(self, item_id):
202 return self.items("/%s/Images" % item_id)
203
204 def get_suggestion(self, media="Movie,Episode", limit=1):
205 return self.users("/Suggestions", params={
206 'Type': media,
207 'Limit': limit
208 })
209
210 def get_recently_added(self, media=None, parent_id=None, limit=20):
211 return self.user_items("/Latest", {
212 'Limit': limit,
213 'UserId': "{UserId}",
214 'IncludeItemTypes': media,
215 'ParentId': parent_id,
216 'Fields': info()
217 })
218
219 def get_next(self, index=None, limit=1):
220 return self.shows("/NextUp", {
221 'Limit': limit,
222 'UserId': "{UserId}",
223 'StartIndex': None if index is None else int(index)
224 })
225
226 def get_adjacent_episodes(self, show_id, item_id):
227 return self.shows("/%s/Episodes" % show_id, {
228 'UserId': "{UserId}",
229 'AdjacentTo': item_id,
230 'Fields': "Overview"
231 })
232
233 def get_season(self, show_id, season_id):
234 return self.shows("/%s/Episodes" % show_id, {
235 'UserId': "{UserId}",
236 'SeasonId': season_id
237 })
238
239 def get_genres(self, parent_id=None):
240 return self._get("Genres", {
241 'ParentId': parent_id,
242 'UserId': "{UserId}",
243 'Fields': info()
244 })
245
246 def get_recommendation(self, parent_id=None, limit=20):
247 return self._get("Movies/Recommendations", {
248 'ParentId': parent_id,
249 'UserId': "{UserId}",
250 'Fields': info(),
251 'Limit': limit
252 })
253
254 # Modified
255 def get_items_by_letter(self, parent_id=None, media=None, letter=None, recurse=True):
256 return self.user_items(params={
257 'ParentId': parent_id,
258 'NameStartsWith': letter,
259 'Fields': info(),
260 'Recursive': recurse,
261 'IncludeItemTypes': media
262 })
263
264 def search_media_items(self, term=None, media=None, limit=20):
265 return self.user_items(params={
266 'searchTerm': term,
267 'Recursive': True,
268 'IncludeItemTypes': media,
269 'Limit': limit
270 })
271
272 def get_channels(self):
273 return self._get("LiveTv/Channels", {
274 'UserId': "{UserId}",
275 'EnableImages': True,
276 'EnableUserData': True
277 })
278
279 def get_intros(self, item_id):
280 return self.user_items("/%s/Intros" % item_id)
281
282 def get_additional_parts(self, item_id):
283 return self.videos("/%s/AdditionalParts" % item_id)
284
285 def delete_item(self, item_id):
286 return self.items("/%s" % item_id, "DELETE")
287
288 def get_local_trailers(self, item_id):
289 return self.user_items("/%s/LocalTrailers" % item_id)
290
291 def get_transcode_settings(self):
292 return self._get('System/Configuration/encoding')
293
294 def get_ancestors(self, item_id):
295 return self.items("/%s/Ancestors" % item_id, params={
296 'UserId': "{UserId}"
297 })
298
299 def get_items_theme_video(self, parent_id):
300 return self.users("/Items", params={
301 'HasThemeVideo': True,
302 'ParentId': parent_id
303 })
304
305 def get_themes(self, item_id):
306 return self.items("/%s/ThemeMedia" % item_id, params={
307 'UserId': "{UserId}",
308 'InheritFromParent': True
309 })
310
311 def get_items_theme_song(self, parent_id):
312 return self.users("/Items", params={
313 'HasThemeSong': True,
314 'ParentId': parent_id
315 })
316
317 def get_plugins(self):
318 return self._get("Plugins")
319
320 def check_companion_installed(self):
321 try:
322 self._get("/Jellyfin.Plugin.KodiSyncQueue/GetServerDateTime")
323 return True
324 except Exception:
325 return False
326
327 def get_seasons(self, show_id):
328 return self.shows("/%s/Seasons" % show_id, params={
329 'UserId': "{UserId}",
330 'EnableImages': True,
331 'Fields': info()
332 })
333
334 def get_date_modified(self, date, parent_id, media=None):
335 return self.users("/Items", params={
336 'ParentId': parent_id,
337 'Recursive': False,
338 'IsMissing': False,
339 'IsVirtualUnaired': False,
340 'IncludeItemTypes': media or None,
341 'MinDateLastSaved': date,
342 'Fields': info()
343 })
344
345 def get_userdata_date_modified(self, date, parent_id, media=None):
346 return self.users("/Items", params={
347 'ParentId': parent_id,
348 'Recursive': True,
349 'IsMissing': False,
350 'IsVirtualUnaired': False,
351 'IncludeItemTypes': media or None,
352 'MinDateLastSavedForUser': date,
353 'Fields': info()
354 })
355
356 def refresh_item(self, item_id):
357 return self.items("/%s/Refresh" % item_id, "POST", json={
358 'Recursive': True,
359 'ImageRefreshMode': "FullRefresh",
360 'MetadataRefreshMode': "FullRefresh",
361 'ReplaceAllImages': False,
362 'ReplaceAllMetadata': True
363 })
364
365 def favorite(self, item_id, option=True):
366 return self.users("/FavoriteItems/%s" % item_id, "POST" if option else "DELETE")
367
368 def get_system_info(self):
369 return self._get("System/Configuration")
370
371 def post_capabilities(self, data):
372 return self.sessions("/Capabilities/Full", "POST", json=data)
373
374 def session_add_user(self, session_id, user_id, option=True):
375 return self.sessions("/%s/Users/%s" % (session_id, user_id), "POST" if option else "DELETE")
376
377 def session_playing(self, data):
378 return self.sessions("/Playing", "POST", json=data)
379
380 def session_progress(self, data):
381 return self.sessions("/Playing/Progress", "POST", json=data)
382
383 def session_stop(self, data):
384 return self.sessions("/Playing/Stopped", "POST", json=data)
385
386 def item_played(self, item_id, watched):
387 return self.users("/PlayedItems/%s" % item_id, "POST" if watched else "DELETE")
388
389 def get_sync_queue(self, date, filters=None):
390 return self._get("Jellyfin.Plugin.KodiSyncQueue/{UserId}/GetItems", params={
391 'LastUpdateDT': date,
392 'filter': filters or None
393 })
394
395 def get_server_time(self):
396 return self._get("Jellyfin.Plugin.KodiSyncQueue/GetServerDateTime")
397
398 def get_play_info(self, item_id, profile, aid=None, sid=None, start_time_ticks=None, is_playback=True):
399 args = {
400 'UserId': "{UserId}",
401 'DeviceProfile': profile,
402 'AutoOpenLiveStream': is_playback,
403 'IsPlayback': is_playback
404 }
405 if sid:
406 args['SubtitleStreamIndex'] = sid
407 if aid:
408 args['AudioStreamIndex'] = aid
409 if start_time_ticks:
410 args['StartTimeTicks'] = start_time_ticks
411 return self.items("/%s/PlaybackInfo" % item_id, "POST", json=args)
412
413 def get_live_stream(self, item_id, play_id, token, profile):
414 return self._post("LiveStreams/Open", json={
415 'UserId': "{UserId}",
416 'DeviceProfile': profile,
417 'OpenToken': token,
418 'PlaySessionId': play_id,
419 'ItemId': item_id
420 })
421
422 def close_live_stream(self, live_id):
423 return self._post("LiveStreams/Close", json={
424 'LiveStreamId': live_id
425 })
426
427 def close_transcode(self, device_id):
428 return self._delete("Videos/ActiveEncodings", params={
429 'DeviceId': device_id
430 })
431
432 def get_audio_stream(self, dest_file, item_id, play_id, container, max_streaming_bitrate=140000000, audio_codec=None):
433 self._get_stream("Audio/%s/universal" % item_id, dest_file, params={
434 'UserId': "{UserId}",
435 'DeviceId': "{DeviceId}",
436 'PlaySessionId': play_id,
437 'Container': container,
438 'AudioCodec': audio_codec,
439 "MaxStreamingBitrate": max_streaming_bitrate,
440 })
441
442 def get_default_headers(self):
443 auth = "MediaBrowser "
444 auth += "Client=%s, " % self.config.data['app.name']
445 auth += "Device=%s, " % self.config.data['app.device_name']
446 auth += "DeviceId=%s, " % self.config.data['app.device_id']
447 auth += "Version=%s" % self.config.data['app.version']
448
449 return {
450 "Accept": "application/json",
451 "Content-type": "application/x-www-form-urlencoded; charset=UTF-8",
452 "X-Application": "%s/%s" % (self.config.data['app.name'], self.config.data['app.version']),
453 "Accept-Charset": "UTF-8,*",
454 "Accept-encoding": "gzip",
455 "User-Agent": self.config.data['http.user_agent'] or "%s/%s" % (self.config.data['app.name'], self.config.data['app.version']),
456 "x-emby-authorization": auth
457 }
458
459 def send_request(self, url, path, method="get", timeout=None, headers=None, data=None, session=None):
460 request_method = getattr(session or requests, method.lower())
461 url = "%s/%s" % (url, path)
462 request_settings = {
463 "timeout": timeout or self.default_timeout,
464 "headers": headers or self.get_default_headers(),
465 "data": data
466 }
467
468 # Changed to use non-Kodi specific setting.
469 if self.config.data.get('auth.ssl') == False:
470 request_settings["verify"] = False
471
472 LOG.info("Sending %s request to %s" % (method, path))
473 LOG.debug(request_settings['timeout'])
474 LOG.debug(request_settings['headers'])
475
476 return request_method(url, **request_settings)
477
478 def login(self, server_url, username, password=""):
479 path = "Users/AuthenticateByName"
480 authData = {
481 "username": username,
482 "Pw": password
483 }
484
485 headers = self.get_default_headers()
486 headers.update({'Content-type': "application/json"})
487
488 try:
489 LOG.info("Trying to login to %s/%s as %s" % (server_url, path, username))
490 response = self.send_request(server_url, path, method="post", headers=headers,
491 data=json.dumps(authData), timeout=(5, 30))
492
493 if response.status_code == 200:
494 return response.json()
495 else:
496 LOG.error("Failed to login to server with status code: " + str(response.status_code))
497 LOG.error("Server Response:\n" + str(response.content))
498 LOG.debug(headers)
499
500 return {}
501 except Exception as e: # Find exceptions for likely cases i.e, server timeout, etc
502 LOG.error(e)
503
504 return {}
505
506 def validate_authentication_token(self, server):
507 authTokenHeader = {
508 'X-MediaBrowser-Token': server['AccessToken']
509 }
510 headers = self.get_default_headers()
511 headers.update(authTokenHeader)
512
513 response = self.send_request(server['address'], "system/info", headers=headers)
514 return response.json() if response.status_code == 200 else {}
515
516 def get_public_info(self, server_address):
517 response = self.send_request(server_address, "system/info/public")
518 return response.json() if response.status_code == 200 else {}
519
520 def check_redirect(self, server_address):
521 ''' Checks if the server is redirecting traffic to a new URL and
522 returns the URL the server prefers to use
523 '''
524 response = self.send_request(server_address, "system/info/public")
525 url = response.url.replace('/system/info/public', '')
526 return url
527
528
529
530 #################################################################################################
531
532 # Syncplay
533
534 #################################################################################################
535
536 def _parse_precise_time(self, time):
537 # We have to remove the Z and the least significant digit.
538 return datetime.strptime(time[:-2], "%Y-%m-%dT%H:%M:%S.%f")
539
540 def utc_time(self):
541 # Measure time as close to the call as is possible.
542 server_address = self.config.data.get("auth.server")
543 session = self.client.session
544
545 response = self.send_request(server_address, "GetUTCTime", session=session)
546 response_received = datetime.utcnow()
547 request_sent = response_received - response.elapsed
548
549 response_obj = response.json()
550 request_received = self._parse_precise_time(response_obj["RequestReceptionTime"])
551 response_sent = self._parse_precise_time(response_obj["ResponseTransmissionTime"])
552
553 return {
554 "request_sent": request_sent,
555 "request_received": request_received,
556 "response_sent": response_sent,
557 "response_received": response_received
558 }
559
560 def get_sync_play(self, item_id=None):
561 params = {}
562 if item_id is not None:
563 params["FilterItemId"] = item_id
564 return self._get("SyncPlay/List", params)
565
566 def join_sync_play(self, group_id):
567 return self._post("SyncPlay/Join", {
568 "GroupId": group_id
569 })
570
571 def leave_sync_play(self):
572 return self._post("SyncPlay/Leave")
573
574 def play_sync_play(self):
575 """deprecated (<= 10.7.0)"""
576 return self._post("SyncPlay/Play")
577
578 def pause_sync_play(self):
579 return self._post("SyncPlay/Pause")
580
581 def unpause_sync_play(self):
582 """10.7.0+ only"""
583 return self._post("SyncPlay/Unpause")
584
585 def seek_sync_play(self, position_ticks):
586 return self._post("SyncPlay/Seek", {
587 "PositionTicks": position_ticks
588 })
589
590 def buffering_sync_play(self, when, position_ticks, is_playing, item_id):
591 return self._post("SyncPlay/Buffering", {
592 "When": when.isoformat() + "Z",
593 "PositionTicks": position_ticks,
594 "IsPlaying": is_playing,
595 "PlaylistItemId": item_id
596 })
597
598 def ready_sync_play(self, when, position_ticks, is_playing, item_id):
599 """10.7.0+ only"""
600 return self._post("SyncPlay/Ready", {
601 "When": when.isoformat() + "Z",
602 "PositionTicks": position_ticks,
603 "IsPlaying": is_playing,
604 "PlaylistItemId": item_id
605 })
606
607 def reset_queue_sync_play(self, queue_item_ids, position=0, position_ticks=0):
608 """10.7.0+ only"""
609 return self._post("SyncPlay/SetNewQueue", {
610 "PlayingQueue": queue_item_ids,
611 "PlayingItemPosition": position,
612 "StartPositionTicks": position_ticks
613 })
614
615 def ignore_sync_play(self, should_ignore):
616 """10.7.0+ only"""
617 return self._post("SyncPlay/SetIgnoreWait", {
618 "IgnoreWait": should_ignore
619 })
620
621 def next_sync_play(self, item_id):
622 """10.7.0+ only"""
623 return self._post("SyncPlay/NextItem", {
624 "PlaylistItemId": item_id
625 })
626
627 def prev_sync_play(self, item_id):
628 """10.7.0+ only"""
629 return self._post("SyncPlay/PreviousItem", {
630 "PlaylistItemId": item_id
631 })
632
633 def set_item_sync_play(self, item_id):
634 """10.7.0+ only"""
635 return self._post("SyncPlay/SetPlaylistItem", {
636 "PlaylistItemId": item_id
637 })
638
639 def ping_sync_play(self, ping):
640 return self._post("SyncPlay/Ping", {
641 "Ping": ping
642 })
643
644 def new_sync_play(self):
645 """deprecated (< 10.7.0)"""
646 return self._post("SyncPlay/New")
647
648 def new_sync_play_v2(self, group_name):
649 """10.7.0+ only"""
650 return self._post("SyncPlay/New", {
651 "GroupName": group_name
652 })
653
diff --git a/jellyfin_apiclient_python/client.py b/jellyfin_apiclient_python/client.py
new file mode 100644
index 0000000..474c47c
--- /dev/null
+++ b/jellyfin_apiclient_python/client.py
@@ -0,0 +1,87 @@
1# -*- coding: utf-8 -*-
2from __future__ import division, absolute_import, print_function, unicode_literals
3
4#################################################################################################
5
6import logging
7
8from . import api
9from .configuration import Config
10from .http import HTTP
11from .ws_client import WSClient
12from .connection_manager import ConnectionManager, CONNECTION_STATE
13from .timesync_manager import TimeSyncManager
14
15#################################################################################################
16
17LOG = logging.getLogger('JELLYFIN.' + __name__)
18
19#################################################################################################
20
21
22def callback(message, data):
23
24 ''' Callback function should received message, data
25 message: string
26 data: json dictionary
27 '''
28 pass
29
30
31class JellyfinClient(object):
32
33 logged_in = False
34
35 def __init__(self, allow_multiple_clients=False):
36 LOG.debug("JellyfinClient initializing...")
37
38 self.config = Config()
39 self.http = HTTP(self)
40 self.wsc = WSClient(self, allow_multiple_clients)
41 self.auth = ConnectionManager(self)
42 self.jellyfin = api.API(self.http)
43 self.callback_ws = callback
44 self.callback = callback
45 self.timesync = TimeSyncManager(self)
46
47 def set_credentials(self, credentials=None):
48 self.auth.credentials.set_credentials(credentials or {})
49
50 def get_credentials(self):
51 return self.auth.credentials.get_credentials()
52
53 def authenticate(self, credentials=None, options=None, discover=True):
54
55 self.set_credentials(credentials or {})
56 state = self.auth.connect(options or {}, discover)
57
58 if state['State'] == CONNECTION_STATE['SignedIn']:
59
60 LOG.info("User is authenticated.")
61 self.logged_in = True
62 self.callback("ServerOnline", {'Id': self.auth.server_id})
63
64 state['Credentials'] = self.get_credentials()
65
66 return state
67
68 def start(self, websocket=False, keep_alive=True):
69
70 if not self.logged_in:
71 raise ValueError("User is not authenticated.")
72
73 self.http.start_session()
74
75 if keep_alive:
76 self.http.keep_alive = True
77
78 if websocket:
79 self.start_wsc()
80
81 def start_wsc(self):
82 self.wsc.start()
83
84 def stop(self):
85 self.wsc.stop_client()
86 self.http.stop_session()
87 self.timesync.stop_ping()
diff --git a/jellyfin_apiclient_python/configuration.py b/jellyfin_apiclient_python/configuration.py
new file mode 100644
index 0000000..1d93ef9
--- /dev/null
+++ b/jellyfin_apiclient_python/configuration.py
@@ -0,0 +1,53 @@
1# -*- coding: utf-8 -*-
2from __future__ import division, absolute_import, print_function, unicode_literals
3
4''' This will hold all configs from the client.
5 Configuration set here will be used for the HTTP client.
6'''
7
8#################################################################################################
9
10import logging
11
12#################################################################################################
13
14DEFAULT_HTTP_MAX_RETRIES = 3
15DEFAULT_HTTP_TIMEOUT = 30
16LOG = logging.getLogger('JELLYFIN.' + __name__)
17
18#################################################################################################
19
20
21class Config(object):
22
23 def __init__(self):
24
25 LOG.debug("Configuration initializing...")
26 self.data = {}
27 self.http()
28
29 def app(self, name, version, device_name, device_id, capabilities=None, device_pixel_ratio=None):
30
31 LOG.debug("Begin app constructor.")
32 self.data['app.name'] = name
33 self.data['app.version'] = version
34 self.data['app.device_name'] = device_name
35 self.data['app.device_id'] = device_id
36 self.data['app.capabilities'] = capabilities
37 self.data['app.device_pixel_ratio'] = device_pixel_ratio
38 self.data['app.default'] = False
39
40 def auth(self, server, user_id, token=None, ssl=None):
41
42 LOG.debug("Begin auth constructor.")
43 self.data['auth.server'] = server
44 self.data['auth.user_id'] = user_id
45 self.data['auth.token'] = token
46 self.data['auth.ssl'] = ssl
47
48 def http(self, user_agent=None, max_retries=DEFAULT_HTTP_MAX_RETRIES, timeout=DEFAULT_HTTP_TIMEOUT):
49
50 LOG.debug("Begin http constructor.")
51 self.data['http.max_retries'] = max_retries
52 self.data['http.timeout'] = timeout
53 self.data['http.user_agent'] = user_agent
diff --git a/jellyfin_apiclient_python/connection_manager.py b/jellyfin_apiclient_python/connection_manager.py
new file mode 100644
index 0000000..9c6a6af
--- /dev/null
+++ b/jellyfin_apiclient_python/connection_manager.py
@@ -0,0 +1,379 @@
1# -*- coding: utf-8 -*-
2from __future__ import division, absolute_import, print_function, unicode_literals
3
4#################################################################################################
5
6import json
7import logging
8import socket
9from datetime import datetime
10from operator import itemgetter
11
12import urllib3
13
14from .credentials import Credentials
15from .api import API
16import traceback
17
18#################################################################################################
19
20LOG = logging.getLogger('JELLYFIN.' + __name__)
21CONNECTION_STATE = {
22 'Unavailable': 0,
23 'ServerSelection': 1,
24 'ServerSignIn': 2,
25 'SignedIn': 3
26}
27
28#################################################################################################
29
30class ConnectionManager(object):
31
32 user = {}
33 server_id = None
34
35 def __init__(self, client):
36
37 LOG.debug("ConnectionManager initializing...")
38
39 self.client = client
40 self.config = client.config
41 self.credentials = Credentials()
42
43 self.API = API(client)
44
45 def clear_data(self):
46
47 LOG.info("connection manager clearing data")
48
49 self.user = None
50 credentials = self.credentials.get_credentials()
51 credentials['Servers'] = list()
52 self.credentials.get_credentials(credentials)
53
54 self.config.auth(None, None)
55
56 def revoke_token(self):
57
58 LOG.info("revoking token")
59
60 self['server']['AccessToken'] = None
61 self.credentials.set_credentials(self.credentials.get())
62
63 self.config.data['auth.token'] = None
64
65 def get_available_servers(self, discover=True):
66
67 LOG.info("Begin getAvailableServers")
68
69 # Clone the credentials
70 credentials = self.credentials.get()
71 found_servers = []
72
73 if discover:
74 found_servers = self.process_found_servers(self._server_discovery())
75
76 if not found_servers and not credentials['Servers']: # back out right away, no point in continuing
77 LOG.info("Found no servers")
78 return list()
79
80 servers = list(credentials['Servers'])
81
82 # Merges servers we already knew with newly found ones
83 for found_server in found_servers:
84 try:
85 self.credentials.add_update_server(servers, found_server)
86 except KeyError:
87 continue
88
89 servers.sort(key=itemgetter('DateLastAccessed'), reverse=True)
90 credentials['Servers'] = servers
91 self.credentials.set(credentials)
92
93 return servers
94
95 def login(self, server_url, username, password=None, clear=None, options=None):
96
97 if not username:
98 raise AttributeError("username cannot be empty")
99
100 if not server_url:
101 raise AttributeError("server url cannot be empty")
102
103 if clear is not None:
104 LOG.warn("The clear option on login() has no effect.")
105
106 if options is not None:
107 LOG.warn("The options option on login() has no effect.")
108
109 data = self.API.login(server_url, username, password) # returns empty dict on failure
110
111 if not data:
112 LOG.info("Failed to login as `"+username+"`")
113 return {}
114
115 LOG.info("Succesfully logged in as %s" % (username))
116 # TODO Change when moving to database storage of server details
117 credentials = self.credentials.get()
118
119 self.config.data['auth.user_id'] = data['User']['Id']
120 self.config.data['auth.token'] = data['AccessToken']
121
122 for server in credentials['Servers']:
123 if server['Id'] == data['ServerId']:
124 found_server = server
125 break
126 else:
127 return {} # No server found
128
129 found_server['DateLastAccessed'] = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')
130 found_server['UserId'] = data['User']['Id']
131 found_server['AccessToken'] = data['AccessToken']
132
133 self.credentials.add_update_server(credentials['Servers'], found_server)
134
135 info = {
136 'Id': data['User']['Id'],
137 'IsSignedInOffline': True
138 }
139 self.credentials.add_update_user(server, info)
140
141 self.credentials.set_credentials(credentials)
142
143 return data
144
145
146 def connect_to_address(self, address, options={}):
147
148 if not address:
149 return False
150
151 address = self._normalize_address(address)
152
153 try:
154 response_url = self.API.check_redirect(address)
155 if address != response_url:
156 address = response_url
157 LOG.info("connect_to_address %s succeeded", address)
158 server = {
159 'address': address,
160 }
161 server = self.connect_to_server(server, options)
162 if server is False:
163 LOG.error("connect_to_address %s failed", address)
164 return { 'State': CONNECTION_STATE['Unavailable'] }
165
166 return server
167 except Exception:
168 LOG.error("connect_to_address %s failed", address)
169 return { 'State': CONNECTION_STATE['Unavailable'] }
170
171
172 def connect_to_server(self, server, options={}):
173
174 LOG.info("begin connect_to_server")
175
176 try:
177 result = self.API.get_public_info(server.get('address'))
178
179 if not result:
180 LOG.error("Failed to connect to server: %s" % server.get('address'))
181 return { 'State': CONNECTION_STATE['Unavailable'] }
182
183 LOG.info("calling onSuccessfulConnection with server %s", server.get('Name'))
184
185 self._update_server_info(server, result)
186 credentials = self.credentials.get()
187 return self._after_connect_validated(server, credentials, result, True, options)
188
189 except Exception as e:
190 LOG.error(traceback.format_exc())
191 LOG.error("Failing server connection. ERROR msg: {}".format(e))
192 return { 'State': CONNECTION_STATE['Unavailable'] }
193
194 def connect(self, options={}, discover=True):
195
196 LOG.info("Begin connect")
197
198 servers = self.get_available_servers(discover)
199 LOG.info("connect has %s servers", len(servers))
200
201 if not (len(servers)): # No servers provided
202 return {
203 'State': ['ServerSelection']
204 }
205
206 result = self.connect_to_server(servers[0], options)
207 LOG.debug("resolving connect with result: %s", result)
208
209 return result
210
211 def jellyfin_user_id(self):
212 return self.get_server_info(self.server_id)['UserId']
213
214 def jellyfin_token(self):
215 return self.get_server_info(self.server_id)['AccessToken']
216
217 def get_server_info(self, server_id):
218
219 if server_id is None:
220 LOG.info("server_id is empty")
221 return {}
222
223 servers = self.credentials.get()['Servers']
224
225 for server in servers:
226 if server['Id'] == server_id:
227 return server
228
229 def get_public_users(self):
230 return self.client.jellyfin.get_public_users()
231
232 def get_jellyfin_url(self, base, handler):
233 return "%s/%s" % (base, handler)
234
235 def _server_discovery(self):
236 MULTI_GROUP = ("<broadcast>", 7359)
237 MESSAGE = b"who is JellyfinServer?"
238
239 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
240 sock.settimeout(1.0) # This controls the socket.timeout exception
241
242 sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 20)
243 sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
244 sock.setsockopt(socket.SOL_IP, socket.IP_MULTICAST_LOOP, 1)
245 sock.setsockopt(socket.IPPROTO_IP, socket.SO_REUSEADDR, 1)
246
247 LOG.debug("MultiGroup : %s", str(MULTI_GROUP))
248 LOG.debug("Sending UDP Data: %s", MESSAGE)
249
250 servers = []
251
252 try:
253 sock.sendto(MESSAGE, MULTI_GROUP)
254 except Exception as error:
255 LOG.exception(traceback.format_exc())
256 LOG.exception(error)
257 return servers
258
259 while True:
260 try:
261 data, addr = sock.recvfrom(1024) # buffer size
262 servers.append(json.loads(data))
263
264 except socket.timeout:
265 LOG.info("Found Servers: %s", servers)
266 return servers
267
268 except Exception as e:
269 LOG.error(traceback.format_exc())
270 LOG.exception("Error trying to find servers: %s", e)
271 return servers
272
273 def process_found_servers(self, found_servers):
274
275 servers = []
276
277 for found_server in found_servers:
278
279 server = self._convert_endpoint_address_to_manual_address(found_server)
280
281 info = {
282 'Id': found_server['Id'],
283 'address': server or found_server['Address'],
284 'Name': found_server['Name']
285 }
286
287 servers.append(info)
288 else:
289 return servers
290
291 # TODO: Make IPv6 compatable
292 def _convert_endpoint_address_to_manual_address(self, info):
293
294 if info.get('Address') and info.get('EndpointAddress'):
295 address = info['EndpointAddress'].split(':')[0]
296
297 # Determine the port, if any
298 parts = info['Address'].split(':')
299 if len(parts) > 1:
300 port_string = parts[len(parts) - 1]
301
302 try:
303 address += ":%s" % int(port_string)
304 return self._normalize_address(address)
305 except ValueError:
306 pass
307
308 return None
309
310 def _normalize_address(self, address):
311 # TODO: Try HTTPS first, then HTTP if that fails.
312 if '://' not in address:
313 address = 'http://' + address
314
315 # Attempt to correct bad input
316 url = urllib3.util.parse_url(address.strip())
317
318 if url.scheme is None:
319 url = url._replace(scheme='http')
320
321 if url.scheme == 'http' and url.port == 80:
322 url = url._replace(port=None)
323
324 if url.scheme == 'https' and url.port == 443:
325 url = url._replace(port=None)
326
327 return url.url
328
329 def _after_connect_validated(self, server, credentials, system_info, verify_authentication, options):
330 if options.get('enableAutoLogin') is False:
331
332 self.config.data['auth.user_id'] = server.pop('UserId', None)
333 self.config.data['auth.token'] = server.pop('AccessToken', None)
334
335 elif verify_authentication and server.get('AccessToken'):
336 system_info = self.API.validate_authentication_token(server)
337 if system_info:
338
339 self._update_server_info(server, system_info)
340 self.config.data['auth.user_id'] = server['UserId']
341 self.config.data['auth.token'] = server['AccessToken']
342
343 return self._after_connect_validated(server, credentials, system_info, False, options)
344
345 server['UserId'] = None
346 server['AccessToken'] = None
347 return { 'State': CONNECTION_STATE['Unavailable'] }
348
349 self._update_server_info(server, system_info)
350
351 server['DateLastAccessed'] = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')
352 self.credentials.add_update_server(credentials['Servers'], server)
353 self.credentials.set(credentials)
354 self.server_id = server['Id']
355
356 # Update configs
357 self.config.data['auth.server'] = server['address']
358 self.config.data['auth.server-name'] = server['Name']
359 self.config.data['auth.server=id'] = server['Id']
360 self.config.data['auth.ssl'] = options.get('ssl', self.config.data['auth.ssl'])
361
362 result = {
363 'Servers': [server]
364 }
365
366 result['State'] = CONNECTION_STATE['SignedIn'] if server.get('AccessToken') else CONNECTION_STATE['ServerSignIn']
367 # Connected
368 return result
369
370 def _update_server_info(self, server, system_info):
371
372 if server is None or system_info is None:
373 return
374
375 server['Name'] = system_info['ServerName']
376 server['Id'] = system_info['Id']
377
378 if system_info.get('address'):
379 server['address'] = system_info['address']
diff --git a/jellyfin_apiclient_python/credentials.py b/jellyfin_apiclient_python/credentials.py
new file mode 100644
index 0000000..715abb5
--- /dev/null
+++ b/jellyfin_apiclient_python/credentials.py
@@ -0,0 +1,128 @@
1# -*- coding: utf-8 -*-
2from __future__ import division, absolute_import, print_function, unicode_literals
3
4#################################################################################################
5
6import logging
7import time
8from datetime import datetime
9
10#################################################################################################
11
12LOG = logging.getLogger('JELLYFIN.' + __name__)
13
14#################################################################################################
15
16
17class Credentials(object):
18
19 credentials = None
20
21 def __init__(self):
22 LOG.debug("Credentials initializing...")
23 self.credentials = {}
24
25 def set_credentials(self, credentials):
26 self.credentials = credentials
27
28 def get_credentials(self):
29 return self.get()
30
31 def _ensure(self):
32
33 if not self.credentials:
34 try:
35 LOG.info(self.credentials)
36 if not isinstance(self.credentials, dict):
37 raise ValueError("invalid credentials format")
38
39 except Exception as e: # File is either empty or missing
40 LOG.warning(e)
41 self.credentials = {}
42
43 LOG.debug("credentials initialized with: %s", self.credentials)
44 self.credentials['Servers'] = self.credentials.setdefault('Servers', [])
45
46 def get(self):
47 self._ensure()
48
49 return self.credentials
50
51 def set(self, data):
52
53 if data:
54 self.credentials.update(data)
55 else:
56 self._clear()
57
58 LOG.debug("credentialsupdated")
59
60 def _clear(self):
61 self.credentials.clear()
62
63 def add_update_user(self, server, user):
64
65 for existing in server.setdefault('Users', []):
66 if existing['Id'] == user['Id']:
67 # Merge the data
68 existing['IsSignedInOffline'] = True
69 break
70 else:
71 server['Users'].append(user)
72
73 def add_update_server(self, servers, server):
74
75 if server.get('Id') is None:
76 raise KeyError("Server['Id'] cannot be null or empty")
77
78 # Add default DateLastAccessed if doesn't exist.
79 server.setdefault('DateLastAccessed', "2001-01-01T00:00:00Z")
80
81 for existing in servers:
82 if existing['Id'] == server['Id']:
83
84 # Merge the data
85 if server.get('DateLastAccessed'):
86 if self._date_object(server['DateLastAccessed']) > self._date_object(existing['DateLastAccessed']):
87 existing['DateLastAccessed'] = server['DateLastAccessed']
88
89 if server.get('UserLinkType'):
90 existing['UserLinkType'] = server['UserLinkType']
91
92 if server.get('AccessToken'):
93 existing['AccessToken'] = server['AccessToken']
94 existing['UserId'] = server['UserId']
95
96 if server.get('ExchangeToken'):
97 existing['ExchangeToken'] = server['ExchangeToken']
98
99 if server.get('ManualAddress'):
100 existing['ManualAddress'] = server['ManualAddress']
101
102 if server.get('LocalAddress'):
103 existing['LocalAddress'] = server['LocalAddress']
104
105 if server.get('Name'):
106 existing['Name'] = server['Name']
107
108 if server.get('LastConnectionMode') is not None:
109 existing['LastConnectionMode'] = server['LastConnectionMode']
110
111 if server.get('ConnectServerId'):
112 existing['ConnectServerId'] = server['ConnectServerId']
113
114 return existing
115 else:
116 servers.append(server)
117 return server
118
119 def _date_object(self, date):
120 # Convert string to date
121 try:
122 date_obj = time.strptime(date, "%Y-%m-%dT%H:%M:%SZ")
123 except (ImportError, TypeError):
124 # TypeError: attribute of type 'NoneType' is not callable
125 # Known Kodi/python error
126 date_obj = datetime(*(time.strptime(date, "%Y-%m-%dT%H:%M:%SZ")[0:6]))
127
128 return date_obj
diff --git a/jellyfin_apiclient_python/exceptions.py b/jellyfin_apiclient_python/exceptions.py
new file mode 100644
index 0000000..6cd5051
--- /dev/null
+++ b/jellyfin_apiclient_python/exceptions.py
@@ -0,0 +1,11 @@
1# -*- coding: utf-8 -*-
2from __future__ import division, absolute_import, print_function, unicode_literals
3
4#################################################################################################
5
6
7class HTTPException(Exception):
8 # Jellyfin HTTP exception
9 def __init__(self, status, message):
10 self.status = status
11 self.message = message
diff --git a/jellyfin_apiclient_python/http.py b/jellyfin_apiclient_python/http.py
new file mode 100644
index 0000000..4cb7ae1
--- /dev/null
+++ b/jellyfin_apiclient_python/http.py
@@ -0,0 +1,267 @@
1# -*- coding: utf-8 -*-
2from __future__ import division, absolute_import, print_function, unicode_literals
3
4#################################################################################################
5
6import json
7import logging
8import time
9import urllib
10
11import requests
12from six import string_types
13
14from .exceptions import HTTPException
15
16#################################################################################################
17
18LOG = logging.getLogger('Jellyfin.' + __name__)
19
20#################################################################################################
21
22
23class HTTP(object):
24
25 session = None
26 keep_alive = False
27
28 def __init__(self, client):
29
30 self.client = client
31 self.config = client.config
32
33 def start_session(self):
34
35 self.session = requests.Session()
36
37 max_retries = self.config.data['http.max_retries']
38 self.session.mount("http://", requests.adapters.HTTPAdapter(max_retries=max_retries))
39 self.session.mount("https://", requests.adapters.HTTPAdapter(max_retries=max_retries))
40
41 def stop_session(self):
42
43 if self.session is None:
44 return
45
46 try:
47 LOG.info("--<[ session/%s ]", id(self.session))
48 self.session.close()
49 except Exception as error:
50 LOG.warning("The requests session could not be terminated: %s", error)
51
52 def _replace_user_info(self, string):
53
54 if '{server}' in string:
55 if self.config.data.get('auth.server', None):
56 string = string.replace("{server}", self.config.data['auth.server'])
57 else:
58 LOG.debug("Server address not set")
59
60 if '{UserId}'in string:
61 if self.config.data.get('auth.user_id', None):
62 string = string.replace("{UserId}", self.config.data['auth.user_id'])
63 else:
64 LOG.debug("UserId is not set.")
65
66 if '{DeviceId}'in string:
67 if self.config.data.get('app.device_id', None):
68 string = string.replace("{DeviceId}", self.config.data['app.device_id'])
69 else:
70 LOG.debug("DeviceId is not set.")
71
72 return string
73
74 def request_url(self, data):
75 if not data:
76 raise AttributeError("Request cannot be empty")
77
78 data = self._request(data)
79
80 params = data["params"]
81 if "api_key" not in params:
82 params["api_key"] = self.config.data.get('auth.token')
83
84 encoded_params = urllib.parse.urlencode(data["params"])
85 return "%s?%s" % (data["url"], encoded_params)
86
87 def request(self, data, session=None, dest_file=None):
88
89 ''' Give a chance to retry the connection. Jellyfin sometimes can be slow to answer back
90 data dictionary can contain:
91 type: GET, POST, etc.
92 url: (optional)
93 handler: not considered when url is provided (optional)
94 params: request parameters (optional)
95 json: request body (optional)
96 headers: (optional),
97 verify: ssl certificate, True (verify using device built-in library) or False
98 '''
99 if not data:
100 raise AttributeError("Request cannot be empty")
101
102 data = self._request(data)
103 LOG.debug("--->[ http ] %s", json.dumps(data, indent=4))
104 retry = data.pop('retry', 5)
105 stream = dest_file is not None
106
107 while True:
108
109 try:
110 r = self._requests(session or self.session or requests, data.pop('type', "GET"), **data, stream=stream)
111 if stream:
112 for chunk in r.iter_content(chunk_size=8192):
113 if chunk: # filter out keep-alive new chunks
114 dest_file.write(chunk)
115 else:
116 r.content # release the connection
117
118 if not self.keep_alive and self.session is not None:
119 self.stop_session()
120
121 r.raise_for_status()
122
123 except requests.exceptions.ConnectionError as error:
124 if retry:
125
126 retry -= 1
127 time.sleep(1)
128
129 continue
130
131 LOG.error(error)
132 self.client.callback("ServerUnreachable", {'ServerId': self.config.data['auth.server-id']})
133
134 raise HTTPException("ServerUnreachable", error)
135
136 except requests.exceptions.ReadTimeout as error:
137 if retry:
138
139 retry -= 1
140 time.sleep(1)
141
142 continue
143
144 LOG.error(error)
145
146 raise HTTPException("ReadTimeout", error)
147
148 except requests.exceptions.HTTPError as error:
149 LOG.error(error)
150
151 if r.status_code == 401:
152
153 if 'X-Application-Error-Code' in r.headers:
154 self.client.callback("AccessRestricted", {'ServerId': self.config.data['auth.server-id']})
155
156 raise HTTPException("AccessRestricted", error)
157 else:
158 self.client.callback("Unauthorized", {'ServerId': self.config.data['auth.server-id']})
159 self.client.auth.revoke_token()
160
161 raise HTTPException("Unauthorized", error)
162
163 elif r.status_code == 500: # log and ignore.
164 LOG.error("--[ 500 response ] %s", error)
165
166 return
167
168 elif r.status_code == 502:
169 if retry:
170
171 retry -= 1
172 time.sleep(1)
173
174 continue
175
176 raise HTTPException(r.status_code, error)
177
178 except requests.exceptions.MissingSchema as error:
179 LOG.error("Request missing Schema. " + str(error))
180 raise HTTPException("MissingSchema", {'Id': self.config.data.get('auth.server', "None")})
181
182 except Exception as error:
183 raise
184
185 else:
186 try:
187 if stream:
188 return
189 self.config.data['server-time'] = r.headers['Date']
190 elapsed = int(r.elapsed.total_seconds() * 1000)
191 response = r.json()
192 LOG.debug("---<[ http ][%s ms]", elapsed)
193 LOG.debug(json.dumps(response, indent=4))
194
195 return response
196 except ValueError:
197 return
198
199 def _request(self, data):
200
201 if 'url' not in data:
202 data['url'] = "%s/%s" % (self.config.data.get("auth.server", ""), data.pop('handler', ""))
203
204 self._get_header(data)
205 data['timeout'] = data.get('timeout') or self.config.data['http.timeout']
206 data['verify'] = data.get('verify') or self.config.data.get('auth.ssl', False)
207 data['url'] = self._replace_user_info(data['url'])
208 self._process_params(data.get('params') or {})
209 self._process_params(data.get('json') or {})
210
211 return data
212
213 def _process_params(self, params):
214
215 for key in params:
216 value = params[key]
217
218 if isinstance(value, dict):
219 self._process_params(value)
220
221 if isinstance(value, string_types):
222 params[key] = self._replace_user_info(value)
223
224 def _get_header(self, data):
225
226 data['headers'] = data.setdefault('headers', {})
227
228 if not data['headers']:
229 data['headers'].update({
230 'Content-type': "application/json",
231 'Accept-Charset': "UTF-8,*",
232 'Accept-encoding': "gzip",
233 'User-Agent': self.config.data['http.user_agent'] or "%s/%s" % (self.config.data.get('app.name', 'Jellyfin for Kodi'), self.config.data.get('app.version', "0.0.0"))
234 })
235
236 if 'x-emby-authorization' not in data['headers']:
237 self._authorization(data)
238
239 return data
240
241 def _authorization(self, data):
242
243 auth = "MediaBrowser "
244 auth += "Client=%s, " % self.config.data.get('app.name', "Jellyfin for Kodi")
245 auth += "Device=%s, " % self.config.data.get('app.device_name', 'Unknown Device')
246 auth += "DeviceId=%s, " % self.config.data.get('app.device_id', 'Unknown Device id')
247 auth += "Version=%s" % self.config.data.get('app.version', '0.0.0')
248
249 data['headers'].update({'x-emby-authorization': auth})
250
251 if self.config.data.get('auth.token') and self.config.data.get('auth.user_id'):
252
253 auth += ', UserId=%s' % self.config.data.get('auth.user_id')
254 data['headers'].update({'x-emby-authorization': auth, 'X-MediaBrowser-Token': self.config.data.get('auth.token')})
255
256 return data
257
258 def _requests(self, session, action, **kwargs):
259
260 if action == "GET":
261 return session.get(**kwargs)
262 elif action == "POST":
263 return session.post(**kwargs)
264 elif action == "HEAD":
265 return session.head(**kwargs)
266 elif action == "DELETE":
267 return session.delete(**kwargs)
diff --git a/jellyfin_apiclient_python/keepalive.py b/jellyfin_apiclient_python/keepalive.py
new file mode 100644
index 0000000..7d9e40e
--- /dev/null
+++ b/jellyfin_apiclient_python/keepalive.py
@@ -0,0 +1,20 @@
1import threading
2
3class KeepAlive(threading.Thread):
4 def __init__(self, timeout, ws):
5 self.halt = threading.Event()
6 self.timeout = timeout
7 self.ws = ws
8
9 threading.Thread.__init__(self)
10
11 def stop(self):
12 self.halt.set()
13 self.join()
14
15 def run(self):
16 while not self.halt.is_set():
17 if self.halt.wait(self.timeout/2):
18 break
19 else:
20 self.ws.send("KeepAlive")
diff --git a/jellyfin_apiclient_python/timesync_manager.py b/jellyfin_apiclient_python/timesync_manager.py
new file mode 100644
index 0000000..5c4e98e
--- /dev/null
+++ b/jellyfin_apiclient_python/timesync_manager.py
@@ -0,0 +1,140 @@
1# This is based on https://github.com/jellyfin/jellyfin-web/blob/master/src/components/syncPlay/timeSyncManager.js
2import threading
3import logging
4import datetime
5
6LOG = logging.getLogger('Jellyfin.' + __name__)
7
8number_of_tracked_measurements = 8
9polling_interval_greedy = 1
10polling_interval_low_profile = 60
11greedy_ping_count = 3
12
13
14class Measurement:
15 def __init__(self, request_sent, request_received, response_sent, response_received):
16 self.request_sent = request_sent
17 self.request_received = request_received
18 self.response_sent = response_sent
19 self.response_received = response_received
20
21 def get_offset(self):
22 """Time offset from server."""
23 return ((self.request_received - self.request_sent) + (self.response_sent - self.response_received)) / 2.0
24
25 def get_delay(self):
26 """Get round-trip delay."""
27 return (self.response_received - self.request_sent) - (self.response_sent - self.request_received)
28
29 def get_ping(self):
30 """Get ping time."""
31 return self.get_delay() / 2.0
32
33
34class _TimeSyncThread(threading.Thread):
35 def __init__(self, manager):
36 self.manager = manager
37 self.halt = threading.Event()
38 threading.Thread.__init__(self)
39
40 def run(self):
41 while not self.halt.wait(self.manager.polling_interval):
42 try:
43 measurement = self.manager.client.jellyfin.utc_time()
44 measurement = Measurement(measurement["request_sent"], measurement["request_received"],
45 measurement["response_sent"], measurement["response_received"])
46
47 self.manager.update_time_offset(measurement)
48
49 if self.manager.pings > greedy_ping_count:
50 self.manager.polling_interval = polling_interval_low_profile
51 else:
52 self.manager.pings += 1
53
54 self.manager._notify_subscribers()
55 except Exception:
56 LOG.error("Timesync call failed.", exc_info=True)
57
58 def stop(self):
59 self.halt.set()
60 self.join()
61
62
63class TimeSyncManager:
64 def __init__(self, client):
65 self.ping_stop = True
66 self.polling_interval = polling_interval_greedy
67 self.poller = None
68 self.pings = 0 # number of pings
69 self.measurement = None # current time sync
70 self.measurements = []
71 self.client = client
72 self.timesync_thread = None
73 self.subscribers = set()
74
75 def is_ready(self):
76 """Gets status of time sync."""
77 return self.measurement is not None
78
79 def get_time_offset(self):
80 """Gets time offset with server."""
81 return self.measurement.get_offset() if self.measurement is not None else datetime.timedelta(0)
82
83 def get_ping(self):
84 """Gets ping time to server."""
85 return self.measurement.get_ping() if self.measurement is not None else datetime.timedelta(0)
86
87 def update_time_offset(self, measurement):
88 """Updates time offset between server and client."""
89 self.measurements.append(measurement)
90 if len(self.measurements) > number_of_tracked_measurements:
91 self.measurements.pop(0)
92
93 self.measurement = min(self.measurements, key=lambda x: x.get_delay())
94
95 def reset_measurements(self):
96 """Drops accumulated measurements."""
97 self.measurement = None
98 self.measurements = []
99
100 def start_ping(self):
101 """Starts the time poller."""
102 if not self.timesync_thread:
103 self.timesync_thread = _TimeSyncThread(self)
104 self.timesync_thread.start()
105
106 def stop_ping(self):
107 """Stops the time poller."""
108 if self.timesync_thread:
109 self.timesync_thread.stop()
110 self.timesync_thread = None
111
112 def force_update(self):
113 """Resets poller into greedy mode."""
114 self.stop_ping()
115 self.polling_interval = polling_interval_greedy
116 self.pings = 0
117 self.start_ping()
118
119 def server_date_to_local(self, server):
120 """Converts server time to local time."""
121 return server - self.get_time_offset()
122
123 def local_date_to_server(self, local):
124 """Converts local time to server time."""
125 return local + self.get_time_offset()
126
127 def subscribe_time_offset(self, subscriber_callable):
128 """Pass a callback function to get notified about time offset changes."""
129 self.subscribers.add(subscriber_callable)
130
131 def remove_subscriber(self, subscriber_callable):
132 """Remove a callback function from notifications."""
133 self.subscribers.remove(subscriber_callable)
134
135 def _notify_subscribers(self):
136 for subscriber in self.subscribers:
137 try:
138 subscriber(self.get_time_offset(), self.get_ping())
139 except Exception:
140 LOG.error("Exception in subscriber callback.")
diff --git a/jellyfin_apiclient_python/ws_client.py b/jellyfin_apiclient_python/ws_client.py
new file mode 100644
index 0000000..d36310b
--- /dev/null
+++ b/jellyfin_apiclient_python/ws_client.py
@@ -0,0 +1,140 @@
1# -*- coding: utf-8 -*-
2from __future__ import division, absolute_import, print_function, unicode_literals
3
4#################################################################################################
5
6import json
7import logging
8import threading
9import ssl
10import certifi
11
12import websocket
13
14from .keepalive import KeepAlive
15
16##################################################################################################
17
18LOG = logging.getLogger('JELLYFIN.' + __name__)
19
20##################################################################################################
21
22
23class WSClient(threading.Thread):
24 multi_client = False
25 global_wsc = None
26 global_stop = False
27
28 def __init__(self, client, allow_multiple_clients=False):
29
30 LOG.debug("WSClient initializing...")
31
32 self.client = client
33 self.keepalive = None
34 self.wsc = None
35 self.stop = False
36 self.message_ids = set()
37
38 if self.multi_client or allow_multiple_clients:
39 self.multi_client = True
40
41 threading.Thread.__init__(self)
42
43 def send(self, message, data=""):
44 if self.wsc is None:
45 raise ValueError("The websocket client is not started.")
46
47 self.wsc.send(json.dumps({'MessageType': message, "Data": data}))
48
49 def run(self):
50
51 token = self.client.config.data['auth.token']
52 device_id = self.client.config.data['app.device_id']
53 server = self.client.config.data['auth.server']
54 server = server.replace('https', "wss") if server.startswith('https') else server.replace('http', "ws")
55 wsc_url = "%s/socket?api_key=%s&device_id=%s" % (server, token, device_id)
56 verify = self.client.config.data.get('auth.ssl', False)
57
58 LOG.info("Websocket url: %s", wsc_url)
59
60 self.wsc = websocket.WebSocketApp(wsc_url,
61 on_message=lambda ws, message: self.on_message(ws, message),
62 on_error=lambda ws, error: self.on_error(ws, error))
63 self.wsc.on_open = lambda ws: self.on_open(ws)
64
65 if not self.multi_client:
66 if self.global_wsc is not None:
67 self.global_wsc.close()
68 self.global_wsc = self.wsc
69
70 while not self.stop and not self.global_stop:
71 if not verify:
72 # https://stackoverflow.com/questions/48740053/
73 self.wsc.run_forever(
74 ping_interval=10, sslopt={"cert_reqs": ssl.CERT_NONE}
75 )
76 else:
77 self.wsc.run_forever(ping_interval=10, sslopt={"ca_certs": certifi.where()})
78
79 if not self.stop:
80 break
81
82 LOG.info("---<[ websocket ]")
83 self.client.callback('WebSocketDisconnect', None)
84
85 def on_error(self, ws, error):
86 LOG.error(error)
87 self.client.callback('WebSocketError', error)
88
89 def on_open(self, ws):
90 LOG.info("--->[ websocket ]")
91 self.client.callback('WebSocketConnect', None)
92
93 def on_message(self, ws, message):
94
95 message = json.loads(message)
96
97 # If a message is received multiple times, ignore repeats.
98 message_id = message.get("MessageId")
99 if message_id is not None:
100 if message_id in self.message_ids:
101 return
102 self.message_ids.add(message_id)
103
104 data = message.get('Data', {})
105
106 if message['MessageType'] == "ForceKeepAlive":
107 self.send("KeepAlive")
108 if self.keepalive is not None:
109 self.keepalive.stop()
110 self.keepalive = KeepAlive(data, self)
111 self.keepalive.start()
112 LOG.debug("ForceKeepAlive received from server.")
113 return
114 elif message['MessageType'] == "KeepAlive":
115 LOG.debug("KeepAlive received from server.")
116 return
117
118 if data is None:
119 data = {}
120 elif type(data) is not dict:
121 data = {"value": data}
122
123 if not self.client.config.data['app.default']:
124 data['ServerId'] = self.client.auth.server_id
125
126 self.client.callback(message['MessageType'], data)
127
128 def stop_client(self):
129
130 self.stop = True
131
132 if self.keepalive is not None:
133 self.keepalive.stop()
134
135 if self.wsc is not None:
136 self.wsc.close()
137
138 if not self.multi_client:
139 self.global_stop = True
140 self.global_wsc = None
diff --git a/joc.py b/joc.py
new file mode 100755
index 0000000..b836222
--- /dev/null
+++ b/joc.py
@@ -0,0 +1,41 @@
1#!/usr/bin/env python3
2
3"""
4This program is free software: you can redistribute it and/or modify
5it under the terms of the GNU General Public License as published by
6the Free Software Foundation, either version 3 of the License, or
7any later version.
8This program is distributed in the hope that it will be useful,
9but WITHOUT ANY WARRANTY; without even the implied warranty of
10MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11GNU General Public License for more details.
12
13You should have received a copy of the GNU General Public License
14along with this program. If not, see <https://www.gnu.org/licenses/>.
15"""
16
17import os
18import sys
19import subprocess
20import urwid
21
22import interface
23import config
24import jellyfin
25import database
26
27def main():
28 if not os.path.exists(os.path.expanduser("~/.config/joc")):
29 os.mkdir(os.path.expanduser("~/.config/joc"))
30 parser = config.read_config_file()
31 dbpath = parser.get("connection","database",
32 fallback=os.path.expanduser("~/.config/joc/jellyfin.db"))
33 db = database.Database(dbpath)
34 conn = jellyfin.JellyfinConnection(parser)
35 libs = conn.get_libraries()
36 tui = interface.Interface(conn, libs, parser, db)
37 tui.start()
38
39
40if __name__ == '__main__':
41 main()