emby-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- emby_cli/__init__.py +6 -0
- emby_cli/__main__.py +4 -0
- emby_cli/_version.py +24 -0
- emby_cli/cli.py +134 -0
- emby_cli/client.py +499 -0
- emby_cli/commands/__init__.py +1 -0
- emby_cli/commands/batch.py +244 -0
- emby_cli/commands/download.py +77 -0
- emby_cli/commands/list.py +35 -0
- emby_cli/commands/play.py +129 -0
- emby_cli/commands/search.py +31 -0
- emby_cli/commands/sync.py +54 -0
- emby_cli/constants.py +9 -0
- emby_cli/download_ops.py +47 -0
- emby_cli/resolve.py +255 -0
- emby_cli/util.py +117 -0
- emby_cli/version.py +17 -0
- emby_cli-0.1.0.dist-info/METADATA +113 -0
- emby_cli-0.1.0.dist-info/RECORD +39 -0
- emby_cli-0.1.0.dist-info/WHEEL +4 -0
- emby_cli-0.1.0.dist-info/entry_points.txt +2 -0
- emby_cli-0.1.0.dist-info/licenses/LICENSE +20 -0
- src/emby_cli/__init__.py +6 -0
- src/emby_cli/__main__.py +4 -0
- src/emby_cli/_version.py +24 -0
- src/emby_cli/cli.py +134 -0
- src/emby_cli/client.py +499 -0
- src/emby_cli/commands/__init__.py +1 -0
- src/emby_cli/commands/batch.py +244 -0
- src/emby_cli/commands/download.py +77 -0
- src/emby_cli/commands/list.py +35 -0
- src/emby_cli/commands/play.py +129 -0
- src/emby_cli/commands/search.py +31 -0
- src/emby_cli/commands/sync.py +54 -0
- src/emby_cli/constants.py +9 -0
- src/emby_cli/download_ops.py +47 -0
- src/emby_cli/resolve.py +255 -0
- src/emby_cli/util.py +117 -0
- src/emby_cli/version.py +17 -0
emby_cli/__init__.py
ADDED
emby_cli/__main__.py
ADDED
emby_cli/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
emby_cli/cli.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Argument parser and entrypoint for emby-cli."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import warnings
|
|
9
|
+
|
|
10
|
+
# macOS system Python ships LibreSSL; urllib3 v2 only warns, TLS still works.
|
|
11
|
+
warnings.filterwarnings("ignore", message="urllib3 v2 only supports OpenSSL")
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
from emby_cli.client import EmbyClient
|
|
16
|
+
from emby_cli.commands.batch import cmd_batch
|
|
17
|
+
from emby_cli.commands.download import cmd_download
|
|
18
|
+
from emby_cli.commands.list import cmd_list
|
|
19
|
+
from emby_cli.commands.play import cmd_play
|
|
20
|
+
from emby_cli.commands.search import cmd_search
|
|
21
|
+
from emby_cli.commands.sync import cmd_sync
|
|
22
|
+
from emby_cli.constants import DEFAULT_OUTPUT
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
26
|
+
env = os.environ.get
|
|
27
|
+
|
|
28
|
+
p = argparse.ArgumentParser(
|
|
29
|
+
prog="emby-cli",
|
|
30
|
+
description="Download / backup original media files from an Emby server via its REST API.",
|
|
31
|
+
)
|
|
32
|
+
p.add_argument("--server", "-s", default=env("EMBY_SERVER"), help="Emby server URL (env: EMBY_SERVER)")
|
|
33
|
+
p.add_argument("--api-key", "-k", default=env("EMBY_API_KEY"), help="API key (env: EMBY_API_KEY)")
|
|
34
|
+
p.add_argument("--username", "-u", default=env("EMBY_USERNAME"), help="Username (env: EMBY_USERNAME)")
|
|
35
|
+
p.add_argument("--password", "-p", default=env("EMBY_PASSWORD"), help="Password (env: EMBY_PASSWORD)")
|
|
36
|
+
|
|
37
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
38
|
+
|
|
39
|
+
ls = sub.add_parser("list", help="List libraries or items in a library")
|
|
40
|
+
ls.add_argument("--library", "-l", help="Library name to list items for")
|
|
41
|
+
|
|
42
|
+
dl = sub.add_parser("download", help="Download items")
|
|
43
|
+
dl.add_argument("--library", "-l", help="Library name to download")
|
|
44
|
+
dl.add_argument("--item-id", "-i", default=env("EMBY_ITEM_ID"), help="Specific item ID to download (env: EMBY_ITEM_ID)")
|
|
45
|
+
dl.add_argument("--output", "-o", default=env("EMBY_OUTPUT", DEFAULT_OUTPUT),
|
|
46
|
+
help=f"Output directory (env: EMBY_OUTPUT, default: {DEFAULT_OUTPUT})")
|
|
47
|
+
dl.add_argument("--force", "-f", action="store_true", help="Re-download even if file exists with matching size")
|
|
48
|
+
dl.add_argument("--throttle", "-t", type=float, nargs="?", const=1.0, default=0,
|
|
49
|
+
help="Limit speed to playback rate. Optional multiplier: 1=realtime, 1.5=50%% faster (default: off)")
|
|
50
|
+
dl.add_argument("--method", "-m", default=env("EMBY_METHOD", "download"),
|
|
51
|
+
choices=["download", "stream", "hls"],
|
|
52
|
+
help="Download method: 'download' (API Download), 'stream' (browser-like original.*), "
|
|
53
|
+
"or 'hls' (stream chunks + remux) (env: EMBY_METHOD)")
|
|
54
|
+
|
|
55
|
+
sr = sub.add_parser("search", help="Search for items by name")
|
|
56
|
+
sr.add_argument("query", help="Search query")
|
|
57
|
+
|
|
58
|
+
pl = sub.add_parser("play", help="Play an item via DirectStreamUrl in an external player")
|
|
59
|
+
pl.add_argument("query", nargs="?",
|
|
60
|
+
help="Title line like batch: 'Movie (2010)' or 'Show (2000) S01E01'")
|
|
61
|
+
pl.add_argument("--item-id", "-i", default=env("EMBY_ITEM_ID"),
|
|
62
|
+
help="Item ID to play (env: EMBY_ITEM_ID)")
|
|
63
|
+
pl.add_argument("--player", default=env("EMBY_PLAYER"),
|
|
64
|
+
help="External player command or path (env: EMBY_PLAYER), e.g. vlc or "
|
|
65
|
+
"/Applications/VLC.app/Contents/MacOS/VLC")
|
|
66
|
+
pl.add_argument("--wait", action="store_true",
|
|
67
|
+
help="Block until the player process exits (default: detach and return)")
|
|
68
|
+
pl.add_argument("--pick-best-item", type=int, choices=[0, 1], default=0,
|
|
69
|
+
help="On ambiguous search results: 0=list and require --item-id (default), "
|
|
70
|
+
"1=auto-select best ≤1080p like batch")
|
|
71
|
+
|
|
72
|
+
sy = sub.add_parser("sync", help="Sync all libraries (or one with --library)")
|
|
73
|
+
sy.add_argument("--library", "-l", default=env("EMBY_LIBRARY"), help="Specific library to sync (env: EMBY_LIBRARY)")
|
|
74
|
+
sy.add_argument("--output", "-o", default=env("EMBY_OUTPUT", DEFAULT_OUTPUT),
|
|
75
|
+
help=f"Output directory (env: EMBY_OUTPUT, default: {DEFAULT_OUTPUT})")
|
|
76
|
+
sy.add_argument("--force", "-f", action="store_true", help="Re-download even if file exists with matching size")
|
|
77
|
+
sy.add_argument("--throttle", "-t", type=float, nargs="?", const=1.0, default=0,
|
|
78
|
+
help="Limit speed to playback rate. Optional multiplier: 1=realtime, 1.5=50%% faster (default: off)")
|
|
79
|
+
sy.add_argument("--method", "-m", default=env("EMBY_METHOD", "download"),
|
|
80
|
+
choices=["download", "stream", "hls"],
|
|
81
|
+
help="Download method: 'download' (API Download), 'stream' (browser-like original.*), "
|
|
82
|
+
"or 'hls' (stream chunks + remux) (env: EMBY_METHOD)")
|
|
83
|
+
|
|
84
|
+
ba = sub.add_parser("batch", help="Download titles from a text file (movies, seasons, episodes)")
|
|
85
|
+
ba.add_argument("--file", "-F", required=True, help="Text file with one title per line")
|
|
86
|
+
ba.add_argument("--dry-run", "-n", action="store_true", help="Search and select only, do not download")
|
|
87
|
+
ba.add_argument("--output", "-o", default=env("EMBY_OUTPUT", DEFAULT_OUTPUT),
|
|
88
|
+
help=f"Output directory (env: EMBY_OUTPUT, default: {DEFAULT_OUTPUT})")
|
|
89
|
+
ba.add_argument("--force", "-f", action="store_true", help="Re-download even if file exists with matching size")
|
|
90
|
+
ba.add_argument("--throttle", "-t", type=float, nargs="?", const=1.0, default=0,
|
|
91
|
+
help="Limit speed to playback rate. Optional multiplier: 1=realtime, 1.5=50%% faster (default: off)")
|
|
92
|
+
ba.add_argument("--method", "-m", default=env("EMBY_METHOD", "download"),
|
|
93
|
+
choices=["download", "stream", "hls"],
|
|
94
|
+
help="Download method: 'download' (API Download), 'stream' (browser-like original.*), "
|
|
95
|
+
"or 'hls' (stream chunks + remux) (env: EMBY_METHOD)")
|
|
96
|
+
|
|
97
|
+
return p
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main() -> None:
|
|
101
|
+
parser = build_parser()
|
|
102
|
+
args = parser.parse_args()
|
|
103
|
+
|
|
104
|
+
if not args.server:
|
|
105
|
+
parser.error("Provide --server or set EMBY_SERVER")
|
|
106
|
+
|
|
107
|
+
if not args.api_key and not args.username:
|
|
108
|
+
parser.error("Provide --api-key / EMBY_API_KEY or --username / EMBY_USERNAME")
|
|
109
|
+
|
|
110
|
+
client = EmbyClient(args.server, api_key=args.api_key)
|
|
111
|
+
|
|
112
|
+
if args.username is not None:
|
|
113
|
+
pw = args.password if args.password is not None else ""
|
|
114
|
+
print(f"Authenticating as '{args.username}'...")
|
|
115
|
+
try:
|
|
116
|
+
client.authenticate(args.username, pw)
|
|
117
|
+
except requests.HTTPError as exc:
|
|
118
|
+
print(f"Authentication failed: {exc}")
|
|
119
|
+
sys.exit(1)
|
|
120
|
+
print("OK\n")
|
|
121
|
+
|
|
122
|
+
commands = {
|
|
123
|
+
"list": cmd_list,
|
|
124
|
+
"download": cmd_download,
|
|
125
|
+
"search": cmd_search,
|
|
126
|
+
"play": cmd_play,
|
|
127
|
+
"sync": cmd_sync,
|
|
128
|
+
"batch": cmd_batch,
|
|
129
|
+
}
|
|
130
|
+
commands[args.command](client, args)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
main()
|
emby_cli/client.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""Emby REST API client: auth, browse, download, stream, HLS."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import shutil
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import urlencode
|
|
10
|
+
|
|
11
|
+
import m3u8
|
|
12
|
+
import requests
|
|
13
|
+
from tqdm import tqdm
|
|
14
|
+
|
|
15
|
+
from emby_cli.constants import (
|
|
16
|
+
CLIENT_NAME,
|
|
17
|
+
DEFAULT_CHUNK,
|
|
18
|
+
DEVICE_NAME,
|
|
19
|
+
MAX_RETRIES,
|
|
20
|
+
RETRY_BACKOFF_BASE,
|
|
21
|
+
)
|
|
22
|
+
from emby_cli.util import remux_segments
|
|
23
|
+
|
|
24
|
+
_DEVICE_ID = hashlib.md5(CLIENT_NAME.encode()).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EmbyClient:
|
|
28
|
+
def __init__(self, server_url: str, api_key: str | None = None):
|
|
29
|
+
self.server_url = server_url.rstrip("/")
|
|
30
|
+
self.api_key = api_key
|
|
31
|
+
self.user_id: str | None = None
|
|
32
|
+
self.access_token: str | None = api_key
|
|
33
|
+
self.session = requests.Session()
|
|
34
|
+
self.session.headers.update({
|
|
35
|
+
"X-Emby-Client": CLIENT_NAME,
|
|
36
|
+
"X-Emby-Device-Name": DEVICE_NAME,
|
|
37
|
+
"X-Emby-Device-Id": _DEVICE_ID,
|
|
38
|
+
"X-Emby-Client-Version": "1.0.0",
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
# -- helpers -------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def _url(self, path: str) -> str:
|
|
44
|
+
return f"{self.server_url}/emby{path}" if not path.startswith("http") else path
|
|
45
|
+
|
|
46
|
+
def _auth_header(self) -> dict:
|
|
47
|
+
parts = [
|
|
48
|
+
f'MediaBrowser Client="{CLIENT_NAME}"',
|
|
49
|
+
f'Device="{DEVICE_NAME}"',
|
|
50
|
+
f'DeviceId="{_DEVICE_ID}"',
|
|
51
|
+
'Version="1.0.0"',
|
|
52
|
+
]
|
|
53
|
+
if self.access_token:
|
|
54
|
+
parts.append(f'Token="{self.access_token}"')
|
|
55
|
+
return {"X-Emby-Authorization": ", ".join(parts)}
|
|
56
|
+
|
|
57
|
+
def _request_with_retry(self, method: str, path: str, **kwargs) -> requests.Response:
|
|
58
|
+
kwargs.setdefault("headers", self._auth_header())
|
|
59
|
+
url = self._url(path)
|
|
60
|
+
for attempt in range(1, MAX_RETRIES + 1):
|
|
61
|
+
try:
|
|
62
|
+
resp = self.session.request(method, url, **kwargs)
|
|
63
|
+
resp.raise_for_status()
|
|
64
|
+
return resp
|
|
65
|
+
except (requests.ConnectionError, requests.Timeout) as exc:
|
|
66
|
+
if attempt == MAX_RETRIES:
|
|
67
|
+
raise
|
|
68
|
+
wait = RETRY_BACKOFF_BASE * (2 ** (attempt - 1))
|
|
69
|
+
print(f" Connection error (attempt {attempt}/{MAX_RETRIES}), retrying in {wait}s: {exc}")
|
|
70
|
+
time.sleep(wait)
|
|
71
|
+
except requests.HTTPError as exc:
|
|
72
|
+
if resp.status_code >= 500 and attempt < MAX_RETRIES:
|
|
73
|
+
wait = RETRY_BACKOFF_BASE * (2 ** (attempt - 1))
|
|
74
|
+
print(f" Server error {resp.status_code} (attempt {attempt}/{MAX_RETRIES}), retrying in {wait}s")
|
|
75
|
+
time.sleep(wait)
|
|
76
|
+
else:
|
|
77
|
+
raise
|
|
78
|
+
raise RuntimeError("unreachable")
|
|
79
|
+
|
|
80
|
+
def _get(self, path: str, params: dict | None = None, **kwargs) -> requests.Response:
|
|
81
|
+
return self._request_with_retry("GET", path, params=params, **kwargs)
|
|
82
|
+
|
|
83
|
+
def _post(self, path: str, payload: dict | None = None) -> requests.Response:
|
|
84
|
+
return self._request_with_retry("POST", path, json=payload)
|
|
85
|
+
|
|
86
|
+
# -- auth ----------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def authenticate(self, username: str, password: str) -> None:
|
|
89
|
+
data = {"Username": username, "Pw": password}
|
|
90
|
+
resp = self._post("/Users/AuthenticateByName", data)
|
|
91
|
+
body = resp.json()
|
|
92
|
+
self.access_token = body["AccessToken"]
|
|
93
|
+
self.user_id = body["User"]["Id"]
|
|
94
|
+
|
|
95
|
+
def resolve_user_id(self) -> str:
|
|
96
|
+
if self.user_id:
|
|
97
|
+
return self.user_id
|
|
98
|
+
users = self._get("/Users").json()
|
|
99
|
+
if not users:
|
|
100
|
+
raise RuntimeError("No users found on server")
|
|
101
|
+
self.user_id = users[0]["Id"]
|
|
102
|
+
return self.user_id
|
|
103
|
+
|
|
104
|
+
# -- browse --------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def get_libraries(self) -> list[dict]:
|
|
107
|
+
uid = self.resolve_user_id()
|
|
108
|
+
resp = self._get(f"/Users/{uid}/Views")
|
|
109
|
+
return resp.json().get("Items", [])
|
|
110
|
+
|
|
111
|
+
def get_items(
|
|
112
|
+
self,
|
|
113
|
+
parent_id: str | None = None,
|
|
114
|
+
item_type: str | None = None,
|
|
115
|
+
recursive: bool = True,
|
|
116
|
+
start: int = 0,
|
|
117
|
+
limit: int = 200,
|
|
118
|
+
) -> dict:
|
|
119
|
+
uid = self.resolve_user_id()
|
|
120
|
+
params: dict = {
|
|
121
|
+
"StartIndex": start,
|
|
122
|
+
"Limit": limit,
|
|
123
|
+
"Recursive": str(recursive).lower(),
|
|
124
|
+
"Fields": "Path,MediaSources,DateCreated,Size,RunTimeTicks",
|
|
125
|
+
"SortBy": "SortName",
|
|
126
|
+
"SortOrder": "Ascending",
|
|
127
|
+
}
|
|
128
|
+
if parent_id:
|
|
129
|
+
params["ParentId"] = parent_id
|
|
130
|
+
if item_type:
|
|
131
|
+
params["IncludeItemTypes"] = item_type
|
|
132
|
+
resp = self._get(f"/Users/{uid}/Items", params=params)
|
|
133
|
+
return resp.json()
|
|
134
|
+
|
|
135
|
+
def get_all_items(
|
|
136
|
+
self,
|
|
137
|
+
parent_id: str | None = None,
|
|
138
|
+
item_type: str | None = None,
|
|
139
|
+
) -> list[dict]:
|
|
140
|
+
"""Page through all items and return the full list."""
|
|
141
|
+
items: list[dict] = []
|
|
142
|
+
start = 0
|
|
143
|
+
batch = 200
|
|
144
|
+
while True:
|
|
145
|
+
page = self.get_items(parent_id=parent_id, item_type=item_type, start=start, limit=batch)
|
|
146
|
+
items.extend(page.get("Items", []))
|
|
147
|
+
total = page.get("TotalRecordCount", 0)
|
|
148
|
+
start += batch
|
|
149
|
+
if start >= total:
|
|
150
|
+
break
|
|
151
|
+
return items
|
|
152
|
+
|
|
153
|
+
def get_item_info(self, item_id: str) -> dict:
|
|
154
|
+
uid = self.resolve_user_id()
|
|
155
|
+
resp = self._get(f"/Users/{uid}/Items/{item_id}")
|
|
156
|
+
return resp.json()
|
|
157
|
+
|
|
158
|
+
def search_items(self, query: str, item_types: str = "Movie", limit: int = 25) -> list[dict]:
|
|
159
|
+
uid = self.resolve_user_id()
|
|
160
|
+
params = {
|
|
161
|
+
"SearchTerm": query,
|
|
162
|
+
"Limit": limit,
|
|
163
|
+
"Recursive": "true",
|
|
164
|
+
"Fields": "Path,MediaSources,MediaStreams,Size,RunTimeTicks,ProductionYear",
|
|
165
|
+
"IncludeItemTypes": item_types,
|
|
166
|
+
}
|
|
167
|
+
resp = self._get(f"/Users/{uid}/Items", params=params)
|
|
168
|
+
return resp.json().get("Items", [])
|
|
169
|
+
|
|
170
|
+
def get_show_episodes(self, series_id: str, season: int | None = None) -> list[dict]:
|
|
171
|
+
uid = self.resolve_user_id()
|
|
172
|
+
params: dict = {
|
|
173
|
+
"UserId": uid,
|
|
174
|
+
"Fields": "Path,MediaSources,MediaStreams,Size,RunTimeTicks,ProductionYear",
|
|
175
|
+
}
|
|
176
|
+
if season is not None:
|
|
177
|
+
params["Season"] = season
|
|
178
|
+
resp = self._get(f"/Shows/{series_id}/Episodes", params=params)
|
|
179
|
+
return resp.json().get("Items", [])
|
|
180
|
+
|
|
181
|
+
# -- playback ------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def get_playback_info(
|
|
184
|
+
self,
|
|
185
|
+
item_id: str,
|
|
186
|
+
media_source_id: str | None = None,
|
|
187
|
+
max_bitrate: int = 120_000_000,
|
|
188
|
+
) -> dict:
|
|
189
|
+
"""Ask the server how this client should play *item_id* (like Emby Web)."""
|
|
190
|
+
uid = self.resolve_user_id()
|
|
191
|
+
params: dict = {
|
|
192
|
+
"UserId": uid,
|
|
193
|
+
"StartTimeTicks": 0,
|
|
194
|
+
"IsPlayback": "true",
|
|
195
|
+
"AutoOpenLiveStream": "true",
|
|
196
|
+
"MaxStreamingBitrate": max_bitrate,
|
|
197
|
+
}
|
|
198
|
+
if media_source_id:
|
|
199
|
+
params["MediaSourceId"] = media_source_id
|
|
200
|
+
# Empty DeviceProfile: let the server decide; Emby Web sends one, but
|
|
201
|
+
# DirectStreamUrl is still returned without it for compatible files.
|
|
202
|
+
resp = self._request_with_retry(
|
|
203
|
+
"POST",
|
|
204
|
+
f"/Items/{item_id}/PlaybackInfo",
|
|
205
|
+
params=params,
|
|
206
|
+
json={},
|
|
207
|
+
)
|
|
208
|
+
return resp.json()
|
|
209
|
+
|
|
210
|
+
def resolve_direct_stream_url(self, item_id: str) -> str:
|
|
211
|
+
"""Return an absolute DirectStreamUrl (browser-style original.* stream)."""
|
|
212
|
+
item_info = self.get_item_info(item_id)
|
|
213
|
+
sources = item_info.get("MediaSources") or []
|
|
214
|
+
media_source_id = sources[0]["Id"] if sources else None
|
|
215
|
+
container = (sources[0].get("Container") if sources else None) or "mp4"
|
|
216
|
+
|
|
217
|
+
info = self.get_playback_info(item_id, media_source_id=media_source_id)
|
|
218
|
+
play_session_id = info.get("PlaySessionId")
|
|
219
|
+
pb_sources = info.get("MediaSources") or []
|
|
220
|
+
source = pb_sources[0] if pb_sources else {}
|
|
221
|
+
|
|
222
|
+
direct = source.get("DirectStreamUrl")
|
|
223
|
+
if not direct:
|
|
224
|
+
if not source.get("SupportsDirectStream", True):
|
|
225
|
+
raise RuntimeError(
|
|
226
|
+
f"Item {item_id} has no DirectStreamUrl "
|
|
227
|
+
f"(SupportsTranscoding={source.get('SupportsTranscoding')}). "
|
|
228
|
+
"Try --method download or --method hls."
|
|
229
|
+
)
|
|
230
|
+
device_id = _DEVICE_ID
|
|
231
|
+
qs = {
|
|
232
|
+
"DeviceId": device_id,
|
|
233
|
+
"MediaSourceId": source.get("Id") or media_source_id or item_id,
|
|
234
|
+
"PlaySessionId": play_session_id
|
|
235
|
+
or hashlib.md5(f"stream-{item_id}-{time.time()}".encode()).hexdigest(),
|
|
236
|
+
}
|
|
237
|
+
if self.access_token:
|
|
238
|
+
qs["api_key"] = self.access_token
|
|
239
|
+
direct = f"/Videos/{item_id}/original.{container}?{urlencode(qs)}"
|
|
240
|
+
|
|
241
|
+
if direct.startswith("http"):
|
|
242
|
+
return direct
|
|
243
|
+
if not direct.startswith("/"):
|
|
244
|
+
direct = "/" + direct
|
|
245
|
+
# PlaybackInfo returns "/videos/..." (no /emby prefix)
|
|
246
|
+
if direct.lower().startswith("/emby/"):
|
|
247
|
+
return f"{self.server_url}{direct}"
|
|
248
|
+
return f"{self.server_url}/emby{direct}"
|
|
249
|
+
|
|
250
|
+
# -- download ------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
def _download_from_url(
|
|
253
|
+
self,
|
|
254
|
+
url: str,
|
|
255
|
+
dest_path: Path,
|
|
256
|
+
chunk_size: int = DEFAULT_CHUNK,
|
|
257
|
+
resume: bool = True,
|
|
258
|
+
rate_bps: float | None = None,
|
|
259
|
+
) -> Path:
|
|
260
|
+
"""Download *url* to *dest_path* with optional resume and rate limit."""
|
|
261
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
262
|
+
tmp_path = dest_path.with_suffix(dest_path.suffix + ".part")
|
|
263
|
+
|
|
264
|
+
headers = dict(self._auth_header())
|
|
265
|
+
existing = 0
|
|
266
|
+
if resume and tmp_path.exists():
|
|
267
|
+
existing = tmp_path.stat().st_size
|
|
268
|
+
headers["Range"] = f"bytes={existing}-"
|
|
269
|
+
|
|
270
|
+
resp = None
|
|
271
|
+
for attempt in range(1, MAX_RETRIES + 1):
|
|
272
|
+
try:
|
|
273
|
+
resp = self.session.get(url, headers=headers, stream=True, timeout=30)
|
|
274
|
+
if resp.status_code == 416:
|
|
275
|
+
if tmp_path.exists():
|
|
276
|
+
tmp_path.rename(dest_path)
|
|
277
|
+
return dest_path
|
|
278
|
+
resp.raise_for_status()
|
|
279
|
+
break
|
|
280
|
+
except (requests.ConnectionError, requests.Timeout) as exc:
|
|
281
|
+
if attempt == MAX_RETRIES:
|
|
282
|
+
raise
|
|
283
|
+
wait = RETRY_BACKOFF_BASE * (2 ** (attempt - 1))
|
|
284
|
+
print(f" Connection error (attempt {attempt}/{MAX_RETRIES}), retrying in {wait}s: {exc}")
|
|
285
|
+
time.sleep(wait)
|
|
286
|
+
except requests.HTTPError:
|
|
287
|
+
if resp is not None and resp.status_code >= 500 and attempt < MAX_RETRIES:
|
|
288
|
+
wait = RETRY_BACKOFF_BASE * (2 ** (attempt - 1))
|
|
289
|
+
print(f" Server error {resp.status_code} (attempt {attempt}/{MAX_RETRIES}), retrying in {wait}s")
|
|
290
|
+
time.sleep(wait)
|
|
291
|
+
else:
|
|
292
|
+
raise
|
|
293
|
+
|
|
294
|
+
total = None
|
|
295
|
+
cl = resp.headers.get("Content-Length")
|
|
296
|
+
if cl:
|
|
297
|
+
total = int(cl) + existing
|
|
298
|
+
|
|
299
|
+
mode = "ab" if existing and resp.status_code == 206 else "wb"
|
|
300
|
+
if mode == "wb":
|
|
301
|
+
existing = 0
|
|
302
|
+
|
|
303
|
+
if rate_bps:
|
|
304
|
+
chunk_size = min(chunk_size, max(16384, int(rate_bps)))
|
|
305
|
+
|
|
306
|
+
t0 = time.monotonic()
|
|
307
|
+
written = 0
|
|
308
|
+
|
|
309
|
+
with (
|
|
310
|
+
open(tmp_path, mode) as fh,
|
|
311
|
+
tqdm(
|
|
312
|
+
total=total,
|
|
313
|
+
initial=existing,
|
|
314
|
+
unit="B",
|
|
315
|
+
unit_scale=True,
|
|
316
|
+
desc=dest_path.name,
|
|
317
|
+
leave=True,
|
|
318
|
+
) as bar,
|
|
319
|
+
):
|
|
320
|
+
for chunk in resp.iter_content(chunk_size=chunk_size):
|
|
321
|
+
fh.write(chunk)
|
|
322
|
+
written += len(chunk)
|
|
323
|
+
bar.update(len(chunk))
|
|
324
|
+
|
|
325
|
+
if rate_bps:
|
|
326
|
+
expected_elapsed = written / rate_bps
|
|
327
|
+
actual_elapsed = time.monotonic() - t0
|
|
328
|
+
if actual_elapsed < expected_elapsed:
|
|
329
|
+
time.sleep(expected_elapsed - actual_elapsed)
|
|
330
|
+
|
|
331
|
+
tmp_path.rename(dest_path)
|
|
332
|
+
return dest_path
|
|
333
|
+
|
|
334
|
+
def download_item(
|
|
335
|
+
self,
|
|
336
|
+
item_id: str,
|
|
337
|
+
dest_path: Path,
|
|
338
|
+
chunk_size: int = DEFAULT_CHUNK,
|
|
339
|
+
resume: bool = True,
|
|
340
|
+
rate_bps: float | None = None,
|
|
341
|
+
) -> Path:
|
|
342
|
+
"""Download the original file for *item_id* via /Items/{id}/Download."""
|
|
343
|
+
return self._download_from_url(
|
|
344
|
+
self._url(f"/Items/{item_id}/Download"),
|
|
345
|
+
dest_path,
|
|
346
|
+
chunk_size=chunk_size,
|
|
347
|
+
resume=resume,
|
|
348
|
+
rate_bps=rate_bps,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def download_item_stream(
|
|
352
|
+
self,
|
|
353
|
+
item_id: str,
|
|
354
|
+
dest_path: Path,
|
|
355
|
+
chunk_size: int = DEFAULT_CHUNK,
|
|
356
|
+
resume: bool = True,
|
|
357
|
+
rate_bps: float | None = None,
|
|
358
|
+
) -> Path:
|
|
359
|
+
"""Download like Emby Web playback: PlaybackInfo + /videos/{id}/original.*."""
|
|
360
|
+
url = self.resolve_direct_stream_url(item_id)
|
|
361
|
+
return self._download_from_url(
|
|
362
|
+
url,
|
|
363
|
+
dest_path,
|
|
364
|
+
chunk_size=chunk_size,
|
|
365
|
+
resume=resume,
|
|
366
|
+
rate_bps=rate_bps,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# -- HLS download --------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
def _download_segment(self, url: str, dest: Path) -> None:
|
|
372
|
+
"""Download a single HLS segment with retry."""
|
|
373
|
+
resp = None
|
|
374
|
+
for attempt in range(1, MAX_RETRIES + 1):
|
|
375
|
+
try:
|
|
376
|
+
resp = self.session.get(
|
|
377
|
+
url, headers=self._auth_header(), stream=True, timeout=30,
|
|
378
|
+
)
|
|
379
|
+
resp.raise_for_status()
|
|
380
|
+
with open(dest, "wb") as f:
|
|
381
|
+
for data in resp.iter_content(chunk_size=DEFAULT_CHUNK):
|
|
382
|
+
f.write(data)
|
|
383
|
+
return
|
|
384
|
+
except (requests.ConnectionError, requests.Timeout) as exc:
|
|
385
|
+
if attempt == MAX_RETRIES:
|
|
386
|
+
raise
|
|
387
|
+
wait = RETRY_BACKOFF_BASE * (2 ** (attempt - 1))
|
|
388
|
+
print(f" Segment retry ({attempt}/{MAX_RETRIES}), waiting {wait}s")
|
|
389
|
+
time.sleep(wait)
|
|
390
|
+
except requests.HTTPError:
|
|
391
|
+
if resp is not None and resp.status_code >= 500 and attempt < MAX_RETRIES:
|
|
392
|
+
wait = RETRY_BACKOFF_BASE * (2 ** (attempt - 1))
|
|
393
|
+
print(f" Segment error {resp.status_code} ({attempt}/{MAX_RETRIES}), waiting {wait}s")
|
|
394
|
+
time.sleep(wait)
|
|
395
|
+
else:
|
|
396
|
+
raise
|
|
397
|
+
|
|
398
|
+
def download_item_hls(self, item_id: str, dest_path: Path, throttle: float = 0) -> Path:
|
|
399
|
+
"""Download via HLS chunks (like a web player) and remux to mkv."""
|
|
400
|
+
dest_path = dest_path.with_suffix(".mkv")
|
|
401
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
402
|
+
|
|
403
|
+
item_info = self.get_item_info(item_id)
|
|
404
|
+
sources = item_info.get("MediaSources", [])
|
|
405
|
+
if not sources:
|
|
406
|
+
raise RuntimeError(f"No media sources for item {item_id}")
|
|
407
|
+
media_source_id = sources[0]["Id"]
|
|
408
|
+
|
|
409
|
+
device_id = _DEVICE_ID
|
|
410
|
+
play_session_id = hashlib.md5(
|
|
411
|
+
f"hls-{item_id}-{time.time()}".encode()
|
|
412
|
+
).hexdigest()
|
|
413
|
+
|
|
414
|
+
hls_params: dict = {
|
|
415
|
+
"DeviceId": device_id,
|
|
416
|
+
"MediaSourceId": media_source_id,
|
|
417
|
+
"PlaySessionId": play_session_id,
|
|
418
|
+
"VideoCodec": "copy",
|
|
419
|
+
"AudioCodec": "copy",
|
|
420
|
+
"SegmentContainer": "ts",
|
|
421
|
+
"BreakOnNonKeyFrames": "false",
|
|
422
|
+
}
|
|
423
|
+
if self.access_token:
|
|
424
|
+
hls_params["api_key"] = self.access_token
|
|
425
|
+
|
|
426
|
+
master_path = f"/Videos/{item_id}/master.m3u8"
|
|
427
|
+
master_full_url = self._url(master_path)
|
|
428
|
+
resp = self._get(master_path, params=hls_params)
|
|
429
|
+
master = m3u8.loads(resp.text, uri=master_full_url)
|
|
430
|
+
|
|
431
|
+
if not master.playlists:
|
|
432
|
+
raise RuntimeError("No variant streams in master playlist")
|
|
433
|
+
|
|
434
|
+
variant_uri = master.playlists[0].absolute_uri
|
|
435
|
+
if self.access_token and "api_key" not in variant_uri:
|
|
436
|
+
sep = "&" if "?" in variant_uri else "?"
|
|
437
|
+
variant_uri += f"{sep}api_key={self.access_token}"
|
|
438
|
+
|
|
439
|
+
tmp_dir = dest_path.parent / f".hls-tmp-{item_id}"
|
|
440
|
+
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
441
|
+
|
|
442
|
+
try:
|
|
443
|
+
segments: list[Path] = []
|
|
444
|
+
seen_uris: set[str] = set()
|
|
445
|
+
playback_clock = 0.0
|
|
446
|
+
t0 = time.monotonic()
|
|
447
|
+
bar = tqdm(unit=" seg", desc=dest_path.name, leave=True)
|
|
448
|
+
|
|
449
|
+
while True:
|
|
450
|
+
resp = self._get(variant_uri)
|
|
451
|
+
media = m3u8.loads(resp.text, uri=variant_uri)
|
|
452
|
+
|
|
453
|
+
if media.is_endlist and bar.total is None:
|
|
454
|
+
bar.total = len(media.segments)
|
|
455
|
+
bar.refresh()
|
|
456
|
+
|
|
457
|
+
for seg in media.segments:
|
|
458
|
+
seg_uri = seg.absolute_uri
|
|
459
|
+
if seg_uri in seen_uris:
|
|
460
|
+
continue
|
|
461
|
+
seen_uris.add(seg_uri)
|
|
462
|
+
|
|
463
|
+
dl_uri = seg_uri
|
|
464
|
+
if self.access_token and "api_key" not in dl_uri:
|
|
465
|
+
sep = "&" if "?" in dl_uri else "?"
|
|
466
|
+
dl_uri += f"{sep}api_key={self.access_token}"
|
|
467
|
+
|
|
468
|
+
seg_path = tmp_dir / f"seg_{len(segments):06d}.ts"
|
|
469
|
+
self._download_segment(dl_uri, seg_path)
|
|
470
|
+
segments.append(seg_path)
|
|
471
|
+
bar.update(1)
|
|
472
|
+
|
|
473
|
+
if throttle and seg.duration:
|
|
474
|
+
playback_clock += seg.duration / throttle
|
|
475
|
+
real_elapsed = time.monotonic() - t0
|
|
476
|
+
if real_elapsed < playback_clock:
|
|
477
|
+
time.sleep(playback_clock - real_elapsed)
|
|
478
|
+
|
|
479
|
+
if media.is_endlist:
|
|
480
|
+
break
|
|
481
|
+
|
|
482
|
+
sleep_time = media.target_duration or 3
|
|
483
|
+
time.sleep(sleep_time)
|
|
484
|
+
|
|
485
|
+
bar.close()
|
|
486
|
+
|
|
487
|
+
if not segments:
|
|
488
|
+
raise RuntimeError("No segments were downloaded")
|
|
489
|
+
|
|
490
|
+
print(f" Remuxing {len(segments)} segments -> {dest_path.name}")
|
|
491
|
+
remux_segments(tmp_dir, segments, dest_path)
|
|
492
|
+
|
|
493
|
+
done_marker = Path(str(dest_path) + ".done")
|
|
494
|
+
done_marker.write_text(str(len(segments)))
|
|
495
|
+
|
|
496
|
+
finally:
|
|
497
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
498
|
+
|
|
499
|
+
return dest_path
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command handlers."""
|