Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Development Branch #35

Draft
wants to merge 21 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 92 additions & 2 deletions jellyfin_apiclient_python/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,31 @@ def refresh_library(self):
"""
return self._post("Library/Refresh")

def add_media_library(self, name, collectionType, paths, refreshLibrary=True):
"""
Create a new media library.

Args:
name (str): name of the new library

collectionType (str): one of "movies" "tvshows" "music" "musicvideos"
"homevideos" "boxsets" "books" "mixed"

paths (List[str]):
paths on the server to use in the media library

References:
.. [AddVirtualFolder] https://api.jellyfin.org/#tag/LibraryStructure/operation/AddVirtualFolder
"""
params = {
'name': name,
'collectionType': collectionType,
'paths': paths,
'refreshLibrary': refreshLibrary,

}
return self.virtual_folders('POST', params=params)

def items(self, handler="", action="GET", params=None, json=None):
if action == "POST":
return self._post("Items%s" % handler, json, params)
Expand All @@ -155,6 +180,12 @@ def items(self, handler="", action="GET", params=None, json=None):
return self._get("Items%s" % handler, params)

def user_items(self, handler="", params=None):
"""
Calls the /Users/{userId}/Items endpoint [GetItemsByUserId]_.

References:
.. [GetItemsByUserId] https://api.jellyfin.org/#tag/Items/operation/GetItemsByUserId
"""
return self.users("/Items%s" % handler, params=params)

def shows(self, handler, params):
Expand Down Expand Up @@ -547,6 +578,34 @@ def favorite(self, item_id, option=True):
def get_system_info(self):
return self._get("System/Configuration")

def get_server_logs(self):
"""
Returns:
List[Dict] - list of information about available log files

References:
.. [GetServerLogs] https://api.jellyfin.org/#tag/System/operation/GetServerLogs
"""
return self._get("System/Logs")

def get_log_entries(self, startIndex=None, limit=None, minDate=None, hasUserId=None):
"""
Returns a list of recent log entries

Returns:
Dict: with main key "Items"
"""
params = {}
if limit is not None:
params['limit'] = limit
if startIndex is not None:
params['startIndex'] = startIndex
if minDate is not None:
params['minDate'] = minDate
if hasUserId is not None:
params['hasUserId'] = hasUserId
return self._get("System/ActivityLog/Entries", params=params)

def post_capabilities(self, data):
return self.sessions("/Capabilities/Full", "POST", json=data)

Expand Down Expand Up @@ -634,19 +693,24 @@ def get_sync_queue(self, date, filters=None):
def get_server_time(self):
return self._get("Jellyfin.Plugin.KodiSyncQueue/GetServerDateTime")

def get_play_info(self, item_id, profile, aid=None, sid=None, start_time_ticks=None, is_playback=True):
def get_play_info(self, item_id, profile=None, aid=None, sid=None, start_time_ticks=None, is_playback=True):
args = {
'UserId': "{UserId}",
'DeviceProfile': profile,
'AutoOpenLiveStream': is_playback,
'IsPlayback': is_playback
}
if profile is None:
args['DeviceProfile'] = profile
if sid:
args['SubtitleStreamIndex'] = sid
if aid:
args['AudioStreamIndex'] = aid
if start_time_ticks:
args['StartTimeTicks'] = start_time_ticks
# TODO:
# Should this be a get?
# https://api.jellyfin.org/#tag/MediaInfo
# https://api.jellyfin.org/#tag/MediaInfo/operation/GetPostedPlaybackInfo
return self.items("/%s/PlaybackInfo" % item_id, "POST", json=args)

def get_live_stream(self, item_id, play_id, token, profile):
Expand Down Expand Up @@ -899,6 +963,32 @@ def identify(client, item_id, provider_ids):
body = {'ProviderIds': provider_ids}
return client.jellyfin.items('/RemoteSearch/Apply/' + item_id, action='POST', params=None, json=body)

def get_now_playing(self, session_id):
"""
Simplified API to get now playing information for a session including the
play state.

References:
https://github.com/jellyfin/jellyfin/issues/9665
"""
resp = self.sessions(params={
'Id': session_id,
'fields': ['PlayState']
})
found = None
for item in resp:
if item['Id'] == session_id:
found = item
if not found:
raise KeyError(f'No session_id={session_id}')
play_state = found['PlayState']
now_playing = found.get('NowPlayingItem', None)
if now_playing is None:
# handle case if nothing is playing
now_playing = {'Name': None}
now_playing['PlayState'] = play_state
return now_playing


class CollectionAPIMixin:
"""
Expand Down
3 changes: 3 additions & 0 deletions jellyfin_apiclient_python/demo/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""
Subpackage for logic related to constructing demo data for tests.
"""
214 changes: 214 additions & 0 deletions jellyfin_apiclient_python/demo/demo_jellyfin_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
"""
Creates a demo jellyfin server using a container interface (e.g. podman or
docker). This is used for automated testing.
"""
import ubelt as ub


class DemoJellyfinServerManager():
"""
Manages a demo jellyfin server.

Has the ability to:

* initialize a new a server

* destroy an existing server

* populate an server with demo data

* check if a server exists

Example:
>>> from jellyfin_apiclient_python.demo.demo_jellyfin_server import * # NOQA
>>> demoman = DemoJellyfinServerManager()
>>> demoman.verbose = 3
>>> demoman.server_exists()
>>> demoman.ensure_server(reset=True)
>>> assert demoman.server_exists()
"""

def __init__(self):
# these can be parameterized in the future
self.jellyfin_image_name = 'jellyfin/jellyfin'
self.oci_container_name = 'jellyfin-apiclient-python-test-server'
self.oci_exe = find_oci_exe()
self.url = 'http://localhost'
self.port = '8097'
self.verbose = 3
# This is where local demo media will be stored
self.test_dpath = ub.Path.appdir('jellyfin-apiclient-python/demo/demo_server')
self.media_dpath = (self.test_dpath / 'media').ensuredir()
# cache_dpath = (test_dpath / 'cache').ensuredir()
# config_dpath = (test_dpath / 'config').ensuredir()

def ensure_server(self, reset=False):
"""
Main entry point that will quickly check if a server exists, and if it
does not it will set up a small one for testing purposes. By passing
reset=True you can delete an existing server and force a fresh start.
"""
if reset:
self.teardown_existing_server()

if not self.server_exists():
self.initialize_new_server()
self.ensure_local_demo_media()
self.populate_demo_media()

def server_exists(self):
"""
Returns:
bool: True there is a container running the jellyfin server
"""
info = ub.cmd(f'{self.oci_exe} ps', verbose=self.verbose)
return self.oci_container_name in info.stdout

def teardown_existing_server(self):
"""
Destroys any server if it exists.
"""
ub.cmd(f'{self.oci_exe} stop {self.oci_container_name}', verbose=self.verbose)
ub.cmd(f'{self.oci_exe} rm {self.oci_container_name}', verbose=self.verbose)

def initialize_new_server(self):
"""
Pulls the OCI image, starts a container running the image, and steps
through the initialization procedure. This results in an initialized,
but empty jellyfin server.
"""
import time

# Ensure we have the jellyfin container image.
ub.cmd(f'{self.oci_exe} pull {self.jellyfin_image_name}', check=True)

# Ensure the media path that we are mounting exists
self.media_dpath.ensuredir()

docker_args = [
'docker', 'run',
'--rm=true',
'--detach=true',
'--name', self.oci_container_name,
'--publish', f'{self.port}:8096/tcp',
# '--user', 'uid:gid',
# Dont mount these so we start with a fresh database on docker
# restart
# '--volume', f'{cache_dpath}:/cache',
# '--volume', f'{config_dpath}:/config',
'--mount', f'type=bind,source={self.media_dpath},target=/media',
# '--restart', 'unless-stopped',
'--restart', 'no',
'jellyfin/jellyfin',
]
ub.cmd(docker_args, verbose=3, check=True)

# Wait for the server to spin up.
info = ub.cmd(f'{self.oci_exe} ps', verbose=self.verbose)
while 'starting' in info.stdout:
time.sleep(3)
info = ub.cmd(f'{self.oci_exe} ps', verbose=self.verbose)

# Programatically initialize the new server with a user with name
# "jellyfin" and password "jellyfin". This process was discovered
# by looking at what the webUI does, and isn't part of the core
# jellyfin API, so it may break in the future.

# References:
# https://matrix.to/#/!YOoxJKhsHoXZiIHyBG:matrix.org/$H4ymY6TE0mtkVEaaxQDNosjLN7xXE__U_gy3u-FGPas?via=bonifacelabs.ca&via=t2bot.io&via=matrix.org
import requests
time.sleep(1)

resp = requests.post(f'{self.url}:{self.port}/Startup/Configuration', json={"UICulture": "en-US", "MetadataCountryCode": "US", "PreferredMetadataLanguage": "en"})
assert resp.ok
time.sleep(1)

resp = requests.get(f'{self.url}:{self.port}/Startup/User')
assert resp.ok
time.sleep(1)

resp = requests.post(f'{self.url}:{self.port}/Startup/User', json={"Name": "jellyfin", "Password": "jellyfin"})
assert resp.ok
time.sleep(1)

payload = {"UICulture": "en-US", "MetadataCountryCode": "US", "PreferredMetadataLanguage": "en"}
resp = requests.post(f'{self.url}:{self.port}/Startup/Configuration', json=payload)
assert resp.ok
time.sleep(1)

payload = {"EnableRemoteAccess": True, "EnableAutomaticPortMapping": False}
resp = requests.post(f'{self.url}:{self.port}/Startup/RemoteAccess', json=payload)
assert resp.ok
time.sleep(1)

resp = requests.post(f'{self.url}:{self.port}/Startup/Complete')
assert resp.ok
time.sleep(1)

def ensure_local_demo_media(self):
"""
Downloads permissive licensed media to the local host.
These will be mounted on our demo jellyfin server.
"""
media_dpath = self.media_dpath
movies_dpath = (media_dpath / 'movies').ensuredir()
music_dpath = (media_dpath / 'music').ensuredir()

# TODO: fix bbb
# zip_fpath = ub.grabdata('https://download.blender.org/demo/movies/BBB/bbb_sunflower_1080p_30fps_normal.mp4.zip',
# dpath=movies_dpath,
# hash_prefix='e320fef389ec749117d0c1583945039266a40f25483881c2ff0d33207e62b362',
# hasher='sha256')
# mp4_fpath = ub.Path(zip_fpath).augment(ext='')
# if not mp4_fpath.exists():
# import zipfile
# zfile = zipfile.ZipFile(zip_fpath)
# zfile.extractall(path=media_dpath)

ub.grabdata('https://commons.wikimedia.org/wiki/File:Zur%C3%BCck_in_die_Zukunft_(Film)_01.ogg', dpath=movies_dpath)
ub.grabdata('https://upload.wikimedia.org/wikipedia/commons/e/e1/Heart_Monitor_Beep--freesound.org.mp3', dpath=music_dpath)
ub.grabdata('https://upload.wikimedia.org/wikipedia/commons/6/63/Clair_de_Lune_-_Wright_Brass_-_United_States_Air_Force_Band_of_Flight.mp3', dpath=music_dpath)
ub.grabdata('https://upload.wikimedia.org/wikipedia/commons/7/73/Schoenberg_-_Drei_Klavierst%C3%BCcke_No._1_-_Irakly_Avaliani.webm', dpath=music_dpath)
ub.grabdata('https://upload.wikimedia.org/wikipedia/commons/6/63/Clair_de_Lune_-_Wright_Brass_-_United_States_Air_Force_Band_of_Flight.mp3', dpath=music_dpath)

def populate_demo_media(self):
"""
Sends API calls to the server to add the demo media to the jellyfin
database.
"""
# Create a client to perform some initial configuration.
from jellyfin_apiclient_python import JellyfinClient
client = JellyfinClient()
client.config.app(
name='DemoServerMediaPopulator',
version='0.1.0',
device_name='machine_name',
device_id='unique_id')
client.config.data["auth.ssl"] = True
url = f'{self.url}:{self.port}'
username = 'jellyfin'
password = 'jellyfin'
client.auth.connect_to_address(url)
client.auth.login(url, username, password)

client.jellyfin.add_media_library(
name='Movies', collectionType='movies',
paths=['/media/movies'], refreshLibrary=True,
)
client.jellyfin.add_media_library(
name='Music', collectionType='music',
paths=['/media/music'], refreshLibrary=True,
)


def find_oci_exe():
"""
Search for docker or podman and return a path to the executable if it
exists, otherwise raise an exception.
"""
oci_exe = ub.find_exe('docker')
if not oci_exe:
oci_exe = ub.find_exe('podman')
if oci_exe is None:
raise Exception('Docker / podman is required')
return oci_exe
Loading
Loading