├── .github └── workflows │ └── python-publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── jellyfin-cli ├── jellyfin_cli ├── __init__.py ├── jellyfin_client │ ├── JellyfinClient.py │ ├── __init__.py │ └── data_classes │ │ ├── Audio.py │ │ ├── Items.py │ │ ├── Movies.py │ │ ├── Shows.py │ │ ├── View.py │ │ └── __init__.py ├── main.py ├── urwid_overrides │ ├── Button.py │ └── __init__.py └── utils │ ├── __init__.py │ ├── login_helper.py │ └── play_utils.py ├── requirements.txt └── setup.py /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | push: 13 | branches: [master] 14 | 15 | jobs: 16 | deploy: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Python 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: '3.x' 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install build 30 | - name: Build package 31 | run: python -m build 32 | - name: Publish package 33 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 34 | with: 35 | user: __token__ 36 | password: ${{ secrets.PYPI_API_TOKEN }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | #generic-user1: updated creds.json gitignore rule 138 | #to apply in any subdirectory, including the repository root 139 | creds.json 140 | */creds.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Marios 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jellyfin CLI 2 | 3 | ##### A Jellyfin command line client written in Python with urwid. 4 | ![Image](https://i.imgur.com/I3Nbd3R.png) 5 | ---- 6 | 7 | ### How to install 8 | This app requires Python3.6 or higher 9 | You can install it by executing `pip3 install --user jellyfin-cli` 10 | You will also need to install MPV. 11 | I recommend symlinking the script to your path so you can execute it directly from the terminal: 12 | This will typically require something like the following: 13 | `ln -s $HOME/.local/bin/jellyfin-cli /usr/bin/jellyfin-cli` 14 | 15 | ### How to use 16 | The first time you run it, you will be greeted by a minimalist login prompt. This will repeat every time the token is invalidated. 17 | After that you can use the interface. 18 | ### Development 19 | 20 | Want to contribute? Sure! You are free to create bug reports, feature requests and pull requests. I will try my best to review all of them. 21 | -------------------------------------------------------------------------------- /jellyfin-cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from jellyfin_cli.main import App 3 | App()() -------------------------------------------------------------------------------- /jellyfin_cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marios8543/Jellyfin-CLI/2a39af48f3ef36cb818b70ed4d8e4af8abaec1fd/jellyfin_cli/__init__.py -------------------------------------------------------------------------------- /jellyfin_cli/jellyfin_client/JellyfinClient.py: -------------------------------------------------------------------------------- 1 | from aiohttp import ClientSession 2 | from jellyfin_cli.jellyfin_client.data_classes.View import View 3 | from jellyfin_cli.jellyfin_client.data_classes.Shows import Episode, Show 4 | from jellyfin_cli.jellyfin_client.data_classes.Movies import Movie 5 | from jellyfin_cli.jellyfin_client.data_classes.Audio import Audio, Album 6 | 7 | class InvalidCredentialsError(Exception): 8 | def __init__(self): 9 | Exception("Invalid username, password or server URL") 10 | 11 | class HttpError(Exception): 12 | def __init__(self, text): 13 | Exception("Something went wrong: {}".format(text)) 14 | 15 | class ServerContext: 16 | def __init__(self, res=None, url=None, client=None, cfg=None, username=None): 17 | if cfg: 18 | self.url = cfg["url"] 19 | self.user_id = cfg["user_id"] 20 | self.server_id = cfg["server_id"] 21 | self.client = ClientSession(headers={ 22 | "x-emby-authorization": cfg["auth_header"] 23 | }) 24 | self.username = cfg["username"] 25 | else: 26 | self.url = url 27 | self.user_id = res["User"]["Id"] 28 | self.server_id = res["ServerId"] 29 | self.username = username 30 | self.client = client 31 | 32 | def get_token(self): 33 | header = self.client._default_headers["x-emby-authorization"] 34 | header = [i.split("=") for i in header.split(",")] 35 | pairs = {k[0].strip().replace('"',""):k[1].strip().replace('"',"") for k in header} 36 | return pairs["Token"] 37 | 38 | class HttpClient: 39 | def __init__(self, server, context=None): 40 | self.client = ClientSession() 41 | self.server = server 42 | self.context = context 43 | 44 | async def login(self, username, password): 45 | try: 46 | res = await self.client.post(self.server+'/Users/authenticatebyname',json={ 47 | "Username": username, 48 | "Pw": password 49 | }, headers={ 50 | "x-emby-authorization":'MediaBrowser Client="Jellyfin CLI", Device="Jellyfin-CLI", DeviceId="None", Version="10.4.3"' 51 | }) 52 | except Exception: 53 | raise InvalidCredentialsError() 54 | if res.status == 200: 55 | res = await res.json() 56 | token = res["AccessToken"] 57 | self.client = ClientSession(headers={ 58 | "x-emby-authorization":'MediaBrowser Client="Jellyfin CLI", Device="Jellyfin-CLI", DeviceId="None", Version="10.4.3", Token="{}"'.format(token) 59 | }) 60 | self.context = ServerContext(res, self.server, self.client, username=username) 61 | from jellyfin_cli.utils.login_helper import store_creds 62 | store_creds(self.context) 63 | return True 64 | elif res.status == 401: 65 | raise InvalidCredentialsError() 66 | else: 67 | raise HttpError(await res.text()) 68 | 69 | async def get_views(self): 70 | res = await self.context.client.get("{}/Users/{}/Views".format(self.context.url, self.context.user_id)) 71 | if res.status == 200: 72 | res = await res.json() 73 | return [View(i, self.context) for i in res["Items"]] 74 | else: 75 | raise HttpError(await res.text()) 76 | 77 | async def get_resume(self, limit=12, types="Video"): 78 | res = await self.context.client.get("{}/Users/{}/Items/Resume".format(self.context.url, self.context.user_id), params={ 79 | "Limit": limit, 80 | "Recursive": "true", 81 | "Fields": "BasicSyncInfo", 82 | "MediaTypes": types 83 | }) 84 | if res.status == 200: 85 | res = await res.json() 86 | return [Episode(r, self.context) for r in res["Items"]] 87 | else: 88 | raise HttpError(await res.text()) 89 | 90 | async def get_nextup(self, limit=24): 91 | res = await self.context.client.get("{}/Shows/NextUp".format(self.context.url), params={ 92 | "UserId": self.context.user_id, 93 | "Limit": limit, 94 | "Recursive": "true", 95 | "Fields": "BasicSyncInfo" 96 | }) 97 | if res.status == 200: 98 | res = await res.json() 99 | return [Episode(r, self.context) for r in res["Items"]] 100 | else: 101 | raise HttpError(await res.text()) 102 | 103 | async def search(self, query, media_type, limit=30): 104 | res = await self.context.client.get("{}/Users/{}/Items".format(self.context.url, self.context.user_id), params={ 105 | "searchTerm": query, 106 | "IncludeItemTypes": media_type, 107 | "IncludeMedia": "true", 108 | "IncludePeople": "false", 109 | "IncludeGenres": "false", 110 | "IncludeStudios": "false", 111 | "IncludeArtists": "false", 112 | "Fields": "BasicSyncInfo", 113 | "Recursive": "true", 114 | "Limit": limit 115 | }) 116 | if res.status == 200: 117 | res = await res.json() 118 | r = [] 119 | for i in res["Items"]: 120 | if i["Type"] == "Movie": 121 | r.append(Movie(i, self.context)) 122 | elif i["Type"] == "Audio": 123 | r.append(Audio(i, self.context)) 124 | elif i["Type"] == "Series": 125 | r.append(Show(i, self.context)) 126 | elif i["Type"] == "Episode": 127 | r.append(Episode(i, self.context)) 128 | elif i["Type"] == "MusicAlbum": 129 | r.append(Album(i, self.context)) 130 | return r 131 | else: 132 | raise HttpError(await res.text()) 133 | 134 | async def get_recommended(self, limit=30): 135 | res = await self.context.client.get("{}/Users/{}/Items".format(self.context.url, self.context.user_id), params={ 136 | "SortBy": "IsFavoriteOrLiked,Random", 137 | "IncludeItemTypes": "Movie,Series", 138 | "Recursive": "true", 139 | "Limit": limit 140 | }) 141 | if res.status == 200: 142 | res = await res.json() 143 | r = [] 144 | for i in res["Items"]: 145 | if i["Type"] == "Movie": 146 | r.append(Movie(i, self.context)) 147 | elif i["Type"] == "Series": 148 | r.append(Show(i, self.context)) 149 | return r 150 | else: 151 | raise HttpError(await res.text()) 152 | 153 | #verifies that the login token is valid by running a request using it 154 | #raises an HttpError if the token is invalid 155 | async def test_token(self): 156 | 157 | response = await self.context.client.get(f"{self.server}/Users/Me") 158 | if response.status != 200: 159 | raise HttpError(await response.text()) -------------------------------------------------------------------------------- /jellyfin_cli/jellyfin_client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marios8543/Jellyfin-CLI/2a39af48f3ef36cb818b70ed4d8e4af8abaec1fd/jellyfin_cli/jellyfin_client/__init__.py -------------------------------------------------------------------------------- /jellyfin_cli/jellyfin_client/data_classes/Audio.py: -------------------------------------------------------------------------------- 1 | from jellyfin_cli.jellyfin_client.data_classes.Items import Item 2 | 3 | class Audio(Item): 4 | def __init__(self, res, context): 5 | super().__init__(res, context) 6 | self.production_year = res["ProductionYear"] if "ProductionYear" in res else None 7 | self.artists = res["Artists"] if "Artists" in res else ["Unknown Artist"] 8 | self.album = res["Album"] if "Album" in res else "Unknown Album" 9 | 10 | class Album(Item): 11 | def __init__(self ,res, context): 12 | super().__init__(res, context) 13 | self.artists = res["Artists"] 14 | self.album_artist = res["AlbumArtist"] if "AlbumArtist" in res and res["AlbumArtist"] else "Unknown Artist" 15 | self.item_count = res["ChildCount"] if "ChildCount" in res else 0 16 | 17 | async def get_songs(self, sort="SortName"): 18 | res = await self.context.client.get("{}/Users/{}/Items".format(self.context.url, self.context.user_id), params={ 19 | "ParentId": self.id, 20 | "Fields": "BasicSyncInfo", 21 | "SortBy": sort 22 | }) 23 | if res.status == 200: 24 | res = await res.json() 25 | r = [] 26 | for i in res["Items"]: 27 | r.append(Audio(i, self.context)) 28 | return r -------------------------------------------------------------------------------- /jellyfin_cli/jellyfin_client/data_classes/Items.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | class Item: 4 | def __init__(self, res, context): 5 | self.id = res["Id"] 6 | self.name = res["Name"] 7 | self.is_folder = res["IsFolder"] 8 | self.played = res["UserData"]["Played"] 9 | if "RunTimeTicks" in res: 10 | self.ticks = res["RunTimeTicks"] 11 | else: 12 | self.ticks = 9 13 | self.context = context 14 | 15 | def __str__(self): 16 | subbed = self.subbed if hasattr(self,"subbed") else False 17 | played = self.played if hasattr(self,"played") else False 18 | return "{}{} {}".format("[P] " if played else "", self.name, "[Subtitled]" if subbed else "") 19 | 20 | class Playlist(Item): 21 | def __init__(self, res, context): 22 | super().__init__(res, context) 23 | 24 | async def get_items(self): 25 | res = await self.context.client.get("{}/Playlists/{}/Items".format(self.context.url, self.id), params={ 26 | "Fields": "BasicSyncInfo", 27 | "UserId": self.context.user_id 28 | }) 29 | if res.status == 200: 30 | res = await res.json() 31 | r = [] 32 | from jellyfin_cli.jellyfin_client.data_classes.Audio import Audio 33 | from jellyfin_cli.jellyfin_client.data_classes.Shows import Episode 34 | from jellyfin_cli.jellyfin_client.data_classes.Movies import Movie 35 | for i in res["Items"]: 36 | if i["Type"] == "Audio": 37 | r.append(Audio(i, self.context)) 38 | elif i["Type"] == "Episode": 39 | r.append(Episode(i, self.context)) 40 | elif i["Type"] == "Movie": 41 | r.append(Movie(i, self.context)) 42 | return r 43 | else: 44 | from jellyfin_cli.jellyfin_client.JellyfinClient import HttpError 45 | raise HttpError(await res.text()) -------------------------------------------------------------------------------- /jellyfin_cli/jellyfin_client/data_classes/Movies.py: -------------------------------------------------------------------------------- 1 | from jellyfin_cli.jellyfin_client.data_classes.Items import Item 2 | from datetime import datetime 3 | 4 | class Movie(Item): 5 | def __init__(self, res, context): 6 | super().__init__(res, context) 7 | self.subbed = res["HasSubtitles"] if "HasSubtitles" in res else False 8 | if "PremiereDate" in res: 9 | self.premiered = datetime.strptime(res["PremiereDate"].split("T")[0], "%Y-%m-%d") 10 | else: 11 | self.premiered = None 12 | self.rating = res["OfficialRating"] if "OfficialRating" in res else None 13 | self.community_rating = res["CommunityRating"] if "CommunityRating" in res else None 14 | 15 | 16 | -------------------------------------------------------------------------------- /jellyfin_cli/jellyfin_client/data_classes/Shows.py: -------------------------------------------------------------------------------- 1 | from jellyfin_cli.jellyfin_client.data_classes.Items import Item 2 | 3 | class Show(Item): 4 | def __init__(self, res, context): 5 | super().__init__(res, context) 6 | 7 | async def get_seasons(self): 8 | res = await self.context.client.get("{}/Shows/{}/Seasons".format(self.context.url, self.id),params={ 9 | "userId": self.context.user_id 10 | }) 11 | if res.status == 200: 12 | res = await res.json() 13 | return [Season(i, self) for i in res["Items"]] 14 | else: 15 | from jellyfin_cli.jellyfin_client.JellyfinClient import HttpError 16 | raise HttpError(await res.text()) 17 | 18 | class Season(Item): 19 | def __init__(self, res, show): 20 | super().__init__(res, show.context) 21 | if "IndexNumber" in res: 22 | self.index = res["IndexNumber"] 23 | else: 24 | self.index = 0 25 | self.is_folder = res["IsFolder"] 26 | self.show = show 27 | 28 | async def get_episodes(self): 29 | res = await self.context.client.get("{}/Shows/{}/Episodes".format(self.context.url, self.show.id), params={ 30 | "seasonId": self.id, 31 | "userId": self.context.user_id 32 | }) 33 | if res.status == 200: 34 | res = await res.json() 35 | return [Episode(i, self.context) for i in res["Items"]] 36 | else: 37 | from jellyfin_cli.jellyfin_client.JellyfinClient import HttpError 38 | raise HttpError(await res.text()) 39 | 40 | class Episode(Item): 41 | def __init__(self, res, context): 42 | super().__init__(res, context) 43 | self.subbed = res["HasSubtitles"] if "HasSubtitles" in res else False 44 | 45 | self.context = context -------------------------------------------------------------------------------- /jellyfin_cli/jellyfin_client/data_classes/View.py: -------------------------------------------------------------------------------- 1 | from jellyfin_cli.jellyfin_client.data_classes.Shows import Show, Episode 2 | from jellyfin_cli.jellyfin_client.data_classes.Movies import Movie 3 | from jellyfin_cli.jellyfin_client.data_classes.Audio import Album, Audio 4 | from jellyfin_cli.jellyfin_client.data_classes.Items import Playlist 5 | 6 | class HttpError(Exception): 7 | pass 8 | 9 | class View: 10 | def __init__(self, res, context): 11 | self.name = res["Name"] 12 | self.id = res["Id"] 13 | self.etag = res["Etag"] 14 | self.parent_id = res["ParentId"] if "ParentId" in res else None 15 | self._fields = "BasicSyncInfo" 16 | self._recursive = True 17 | if "CollectionType" not in res: 18 | self.view_type = "Mixed" 19 | self._sort = "IsFolder,SortName" 20 | self._fields = "SortName" 21 | self._recursive = False 22 | elif res["CollectionType"] == "movies": 23 | self._sort = "DateCreated,SortName,ProductionYear" 24 | self.view_type = "Movie" 25 | elif res["CollectionType"] == "tvshows": 26 | self._sort = "DateCreated,SortName,ProductionYear" 27 | self.view_type = "Series" 28 | elif res["CollectionType"] == "music": 29 | self._sort = "DatePlayed" 30 | self.view_type = "Audio" 31 | elif res["CollectionType"] == "playlists": 32 | self._sort = "IsFolder,SortName" 33 | self.view_type = "Playlist" 34 | self.context = context 35 | 36 | def __str__(self): 37 | return self.name 38 | 39 | async def get_items(self, start=0, limit=100, sort=None): 40 | if not sort: 41 | sort = self._sort 42 | params={ 43 | "SortBy": sort, 44 | "SortOrder": "Descending", 45 | "Recursive": "true" if self._recursive else "false", 46 | "Fields": self._fields, 47 | "StartIndex": start, 48 | "Limit": limit, 49 | "ParentId": self.id 50 | } 51 | if self.view_type != "Mixed": 52 | params["IncludeItemTypes"] = self.view_type 53 | res = await self.context.client.get("{}/Users/{}/Items".format(self.context.url,self.context.user_id), params=params) 54 | if res.status == 200: 55 | res = await res.json() 56 | r = [] 57 | for i in res["Items"]: 58 | if i["Type"] == "Movie": 59 | r.append(Movie(i, self.context)) 60 | elif i["Type"] == "Audio": 61 | r.append(Audio(i, self.context)) 62 | elif i["Type"] == "Series": 63 | r.append(Show(i, self.context)) 64 | elif i["Type"] == "MusicAlbum": 65 | r.append(Album(i, self.context)) 66 | elif i["Type"] == "Episode": 67 | r.append(Episode(i, self.context)) 68 | return r 69 | else: 70 | raise HttpError(await res.text()) 71 | 72 | async def get_latest(self, limit=30): 73 | res = await self.context.client.get("{}/Users/{}/Items/Latest".format(self.context.url, self.context.user_id), params={ 74 | "Limit": limit, 75 | "ParentId": self.id, 76 | "Fields": "BasicSyncInfo" 77 | }) 78 | if res.status == 200: 79 | res = await res.json() 80 | r = [] 81 | for i in res: 82 | if i["Type"] == "Movie": 83 | r.append(Movie(i, self.context)) 84 | elif i["Type"] == "Audio": 85 | r.append(Audio(i, self.context)) 86 | elif i["Type"] == "Series": 87 | r.append(Show(i, self.context)) 88 | elif i["Type"] == "MusicAlbum": 89 | r.append(Album(i, self.context)) 90 | elif i["Type"] == "Episode": 91 | r.append(Episode(i, self.context)) 92 | return r 93 | else: 94 | raise HttpError(await res.text()) -------------------------------------------------------------------------------- /jellyfin_cli/jellyfin_client/data_classes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marios8543/Jellyfin-CLI/2a39af48f3ef36cb818b70ed4d8e4af8abaec1fd/jellyfin_cli/jellyfin_client/data_classes/__init__.py -------------------------------------------------------------------------------- /jellyfin_cli/main.py: -------------------------------------------------------------------------------- 1 | from jellyfin_cli.utils.login_helper import login 2 | from jellyfin_cli.utils.play_utils import Player 3 | from asyncio import get_event_loop, sleep 4 | from jellyfin_cli.urwid_overrides.Button import ButtonNoArrows as Button 5 | import urwid 6 | 7 | def calculate_percentage(rows, prcnt): 8 | return int((rows/100)*prcnt) 9 | 10 | PALLETE = [ 11 | ('bg', 'black', 'light gray'), 12 | ('bg_inverted', 'white', 'light gray') 13 | ] 14 | 15 | class App: 16 | def __init__(self): 17 | self.loop = get_event_loop() 18 | self.client = self.loop.run_until_complete(login()) 19 | self.player = Player(self.client.context) 20 | self.widgets = [urwid.Text("Logged in as {}".format(self.client.context.username)), urwid.Divider(bottom=1)] 21 | self.draw = urwid.MainLoop(urwid.Filler(urwid.Pile([])), palette=PALLETE, event_loop=urwid.AsyncioEventLoop(loop=self.loop)) 22 | self.previous_key_callback = (None, (None)) 23 | self.draw.unhandled_input = self._process_keypress 24 | 25 | self._last_view = self.draw_search 26 | self.search_query = "" 27 | self.search_edit = urwid.Edit("Search: ", edit_text=self.search_query) 28 | 29 | def _process_keypress(self, key): 30 | if key == "tab": 31 | try: 32 | if self.previous_key_callback[1]: 33 | self.previous_key_callback[0](*self.previous_key_callback[1]) 34 | else: 35 | self.previous_key_callback[0]() 36 | except: 37 | pass 38 | elif key == "s": 39 | self.draw_search(None, "") 40 | elif key == "p": 41 | if self.player.playing: 42 | self.player.pause() 43 | elif key == "q": 44 | def _(x=None): 45 | raise urwid.ExitMainLoop() 46 | if self.player.playing: 47 | task = self.loop.create_task(self.player.stop()) 48 | task.add_done_callback(_) 49 | _() 50 | 51 | def _draw_table(self, items, title=None, prcnt=30, callback=None): 52 | if not callback: 53 | callback = self.play 54 | rows = calculate_percentage(self.draw.screen.get_cols_rows()[1], prcnt) 55 | texts = [Button(str(i), on_press=callback(i) if not hasattr(self, callback.__name__) else callback, user_data=i) for i in items] 56 | widgets = [urwid.Text(("bg", title), align=urwid.CENTER)] if title else [] 57 | widgets.append(urwid.BoxAdapter(urwid.ListBox(urwid.SimpleFocusListWalker(texts)), rows)) 58 | return widgets 59 | 60 | def _draw_screen(self): 61 | self.draw.widget.body = urwid.Pile(self.widgets) 62 | 63 | def _empty_screen(self, text=""): 64 | self.draw.widget.body = urwid.Text(text) 65 | 66 | def _clear_widgets(self): 67 | self.widgets = [urwid.Text("Logged in as {}".format(self.client.context.username)), urwid.Divider(bottom=1)] 68 | 69 | def _clear_search(self): 70 | self.search_query = "" 71 | self.search_edit = urwid.Edit() 72 | 73 | def _add_widget(self, wd): 74 | if type(wd) == list: 75 | self.widgets.extend(wd) 76 | else: 77 | self.widgets.append(wd) 78 | self.widgets.append(urwid.Divider(div_char="_", top=1, bottom=1)) 79 | self.draw.widget.body = urwid.Pile(self.widgets) 80 | 81 | async def _draw_home(self): 82 | self.widgets = [urwid.Text("Logged in as {}".format(self.client.context.username)), urwid.Divider(bottom=1)] 83 | views = await self.client.get_views() 84 | columns = urwid.Columns([Button(("bg", i.name), on_press=self.draw_view, user_data=i) for i in views]) 85 | self._add_widget(columns) 86 | resume = await self.client.get_resume() 87 | nextup = await self.client.get_nextup() 88 | self._add_widget(self._draw_table(resume,"Continue Watching", prcnt=30)) 89 | self._add_widget(self._draw_table(nextup, "Next Up", prcnt=28)) 90 | self._add_widget(urwid.Text("Tab: Go back | s: Search | p: Play/Pause | q: Exit")) 91 | self.previous_key_callback = (None, None) 92 | 93 | def draw_home(self): 94 | self.loop.create_task(self._draw_home()) 95 | 96 | async def _draw_view(self, view): 97 | self._clear_widgets() 98 | if view.view_type == "Audio": 99 | await self._draw_audio(view) 100 | elif view.view_type == "Playlist": 101 | self._last_view = view 102 | items = await view.get_items() 103 | self._add_widget(self._draw_table(items, str(view), prcnt=98, callback=self.draw_playlist)) 104 | elif view.view_type == "Mixed": 105 | items = await view.get_items(limit=500) 106 | callbacks = {"Movie":self.play, "Show": self.draw_seasons, "Episode": self.play, "Audio": self.play_bg, "Album": self.draw_album} 107 | self._add_widget(self._draw_table(items, str(view), prcnt=98, callback=lambda i: callbacks[i.__class__.__name__])) 108 | else: 109 | items = await view.get_items(limit=500) 110 | if view.view_type == "Series": 111 | callback = self.draw_seasons 112 | else: 113 | callback = None 114 | self._add_widget(self._draw_table(items, str(view), prcnt=96, callback=callback)) 115 | self._last_view = view 116 | self.previous_key_callback = (self.draw_home, None) 117 | 118 | def draw_view(self, b, view): 119 | self.loop.create_task(self._draw_view(view)) 120 | 121 | async def _draw_playlist(self, playlist): 122 | items = await playlist.get_items() 123 | callbacks = {"Movie":self.play, "Episode": self.play, "Audio": self.play_bg} 124 | self._clear_widgets() 125 | self._add_widget(self._draw_table(items, playlist.name, prcnt=98, callback=lambda i: callbacks[i.__class__.__name__])) 126 | self.previous_key_callback = (self.draw_view, (None, self._last_view)) if not callable(self._last_view) else (self._last_view, (None,None)) 127 | 128 | def draw_playlist(self, b, playlist): 129 | self.loop.create_task(self._draw_playlist(playlist)) 130 | 131 | async def _draw_audio(self, view): 132 | latest = await view.get_latest(limit=16) 133 | recent = await view.get_items() 134 | frequent = await view.get_items(sort="PlayCount", limit=8) 135 | self._add_widget(self._draw_table(latest, title="Latest Music", callback=self.draw_album, prcnt=15)) 136 | self._add_widget(self._draw_table(recent, title="Recently Played", callback=self.play_bg, prcnt=40)) 137 | self._add_widget(self._draw_table(frequent, title="Frequently Played", callback=self.play_bg, prcnt=15)) 138 | self._last_view = view 139 | self.previous_key_callback = (self.draw_home, None) 140 | 141 | async def _draw_album(self, album): 142 | items = await album.get_songs() 143 | self._clear_widgets() 144 | self._add_widget(self._draw_table(items, title=album.name, prcnt=97, callback=self.play_bg)) 145 | self.previous_key_callback = (self.draw_view, (None, self._last_view)) if not callable(self._last_view) else (self._last_view, (None, None)) 146 | 147 | def draw_album(self, b, album): 148 | self.loop.create_task(self._draw_album(album)) 149 | 150 | async def _draw_seasons(self, series): 151 | seasons = await series.get_seasons() 152 | self._clear_widgets() 153 | self._add_widget(self._draw_table(seasons, str(series), prcnt=96, callback=self.draw_episodes)) 154 | self.previous_key_callback = (self.draw_view, (None, self._last_view)) if not callable(self._last_view) else (self._last_view, (None,None)) 155 | 156 | def draw_seasons(self, b, series, callback=None): 157 | self.loop.create_task(self._draw_seasons(series)) 158 | 159 | async def _draw_episodes(self, season): 160 | episodes = await season.get_episodes() 161 | self._clear_widgets() 162 | self._add_widget(self._draw_table(episodes, "{} - {}".format(season.show, season), prcnt=96)) 163 | self.previous_key_callback = (self.draw_seasons, (None, season.show)) 164 | 165 | def draw_episodes(self, b, season): 166 | self.loop.create_task(self._draw_episodes(season)) 167 | 168 | async def _draw_search(self): 169 | self._clear_widgets() 170 | if len(self.search_query) > 0: 171 | movies = await self.client.search(self.search_query, "Movie") 172 | shows = await self.client.search(self.search_query, "Series") 173 | episodes = await self.client.search(self.search_query, "Episode") 174 | songs = await self.client.search(self.search_query, "Audio") 175 | albums = await self.client.search(self.search_query, "MusicAlbum") 176 | self._add_widget(self.search_edit) 177 | if movies: 178 | self._add_widget(self._draw_table(movies, "Movies", callback=self.play, prcnt=20)) 179 | if shows: 180 | self._add_widget(self._draw_table(shows, "Shows", callback=self.draw_seasons, prcnt=20)) 181 | if episodes: 182 | self._add_widget(self._draw_table(episodes, "Episodes", callback=self.play, prcnt=20)) 183 | if songs: 184 | self._add_widget(self._draw_table(songs, "Songs", callback=self.play_bg, prcnt=20)) 185 | if albums: 186 | self._add_widget(self._draw_table(albums, "Albums", callback=self.draw_album, prcnt=20)) 187 | else: 188 | urwid.connect_signal(self.search_edit, "change", self.draw_search) 189 | recommended = await self.client.get_recommended() 190 | self._add_widget(self.search_edit) 191 | callbacks = {"Movie":self.play, "Show": self.draw_seasons, "Episode": self.play} 192 | self._add_widget(self._draw_table(recommended, "Suggestions", prcnt=96, callback=lambda i: callbacks[i.__class__.__name__])) 193 | self.previous_key_callback = (lambda: [self.draw_home(), urwid.disconnect_signal(self.search_edit, "change", self.draw_search), self._clear_search()], (None)) 194 | 195 | def draw_search(self, e, text): 196 | self.search_query = text if text is not None else "" 197 | self.loop.create_task(self._draw_search()) 198 | 199 | async def _play(self, item): 200 | self._empty_screen() 201 | await self.player._play(item) 202 | self._draw_screen() 203 | 204 | async def _bg_play(self , item): 205 | await self.player._play(item, block=False) 206 | while self.player.playing: 207 | play_string = self.player.get_playback_string() 208 | if not self.previous_key_callback[0]: 209 | try: 210 | self.draw.widget.body.contents[-2][0].set_text(play_string) 211 | self.draw.draw_screen() 212 | except: 213 | break 214 | await sleep(1) 215 | 216 | def play(self, b, item, bg=False): 217 | if bg: 218 | self.loop.create_task(self._bg_play(item)) 219 | else: 220 | self.loop.create_task(self._play(item)) 221 | 222 | def play_bg(self, b, item): 223 | self.play(b, item, bg=True) 224 | 225 | def _run(self): 226 | self.draw_home() 227 | self.draw.run() 228 | 229 | def __call__(self): 230 | return self._run() 231 | 232 | app = App() 233 | if __name__ == "__main__": 234 | app() -------------------------------------------------------------------------------- /jellyfin_cli/urwid_overrides/Button.py: -------------------------------------------------------------------------------- 1 | from urwid import Button as urwid_button 2 | from urwid import SelectableIcon as urwid_selectable 3 | from urwid import Text 4 | from urwid import Columns 5 | from urwid.text_layout import calc_coords 6 | from urwid import WidgetWrap 7 | from urwid.widget import FLOW 8 | from urwid.command_map import ACTIVATE 9 | from urwid.signals import connect_signal 10 | from urwid.split_repr import python3_repr 11 | from urwid.util import is_mouse_press 12 | 13 | class SelectableIconBg(Text): 14 | ignore_focus = False 15 | _selectable = True 16 | def __init__(self, text, cursor_position=0): 17 | super().__init__(text) 18 | self._text = text 19 | self._cursor_position = cursor_position 20 | 21 | def render(self, size, focus=False): 22 | if focus: 23 | super().set_text(("bg_inverted", self._text)) 24 | else: 25 | super().set_text(self._text) 26 | c = super().render(size, focus) 27 | return c 28 | 29 | 30 | def keypress(self, size, key): 31 | return key 32 | 33 | class ButtonNoArrows(WidgetWrap): 34 | def sizing(self): 35 | return frozenset([FLOW]) 36 | 37 | button_left = Text("") 38 | button_right = Text("") 39 | 40 | signals = ["click"] 41 | 42 | def __init__(self, label, on_press=None, user_data=None): 43 | """ 44 | :param label: markup for button label 45 | :param on_press: shorthand for connect_signal() 46 | function call for a single callback 47 | :param user_data: user_data for on_press 48 | Signals supported: ``'click'`` 49 | Register signal handler with:: 50 | urwid.connect_signal(button, 'click', callback, user_data) 51 | where callback is callback(button [,user_data]) 52 | Unregister signal handlers with:: 53 | urwid.disconnect_signal(button, 'click', callback, user_data) 54 | >>> Button(u"Ok") 55 |