emby-cli 0.2.2__tar.gz → 0.3.0__tar.gz
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-0.2.2 → emby_cli-0.3.0}/.env.example +2 -1
- {emby_cli-0.2.2 → emby_cli-0.3.0}/CHANGELOG.md +24 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/PKG-INFO +34 -13
- emby_cli-0.3.0/README.md +95 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/_version.py +2 -2
- emby_cli-0.3.0/src/emby_cli/auth_cache.py +323 -0
- emby_cli-0.3.0/src/emby_cli/cli.py +301 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/client.py +149 -15
- emby_cli-0.3.0/src/emby_cli/commands/config.py +102 -0
- emby_cli-0.3.0/src/emby_cli/commands/download.py +348 -0
- emby_cli-0.3.0/src/emby_cli/commands/help.py +27 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/commands/info.py +23 -19
- emby_cli-0.3.0/src/emby_cli/commands/login.py +29 -0
- emby_cli-0.3.0/src/emby_cli/commands/logout.py +73 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/commands/play.py +25 -15
- emby_cli-0.3.0/src/emby_cli/commands/search.py +139 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/commands/version.py +7 -8
- {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/constants.py +3 -0
- emby_cli-0.3.0/src/emby_cli/credentials.py +115 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/download_ops.py +38 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/resolve.py +5 -5
- emby_cli-0.3.0/tests/test_auth_cache.py +152 -0
- emby_cli-0.3.0/tests/test_config.py +73 -0
- emby_cli-0.3.0/tests/test_credentials.py +125 -0
- emby_cli-0.3.0/tests/test_download_ops.py +33 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/tests/test_info.py +10 -2
- emby_cli-0.3.0/tests/test_logout.py +90 -0
- emby_cli-0.3.0/tests/test_parser.py +207 -0
- emby_cli-0.3.0/tests/test_search.py +52 -0
- emby_cli-0.3.0/tests/test_session_auth.py +123 -0
- emby_cli-0.3.0/tests/test_validate_args.py +156 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/tests/test_version.py +5 -2
- emby_cli-0.2.2/README.md +0 -74
- emby_cli-0.2.2/src/emby_cli/cli.py +0 -167
- emby_cli-0.2.2/src/emby_cli/commands/batch.py +0 -158
- emby_cli-0.2.2/src/emby_cli/commands/download.py +0 -71
- emby_cli-0.2.2/src/emby_cli/commands/list.py +0 -50
- emby_cli-0.2.2/src/emby_cli/commands/search.py +0 -30
- emby_cli-0.2.2/src/emby_cli/commands/sync.py +0 -46
- emby_cli-0.2.2/tests/test_parser.py +0 -80
- {emby_cli-0.2.2 → emby_cli-0.3.0}/.github/workflows/ci.yml +0 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/.github/workflows/publish.yml +0 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/.gitignore +0 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/LICENSE +0 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/pyproject.toml +0 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/__init__.py +0 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/__main__.py +0 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/commands/__init__.py +0 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/output.py +0 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/util.py +0 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/version.py +0 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/tests/test_client.py +0 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/tests/test_output.py +0 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/tests/test_resolve.py +0 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/tests/test_resolve_table.py +0 -0
- {emby_cli-0.2.2 → emby_cli-0.3.0}/tests/test_util.py +0 -0
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.3.0
|
|
6
|
+
|
|
7
|
+
Breaking:
|
|
8
|
+
|
|
9
|
+
- Removed `batch`, `sync`, and `list` subcommands (`search --library --all` lists all libraries).
|
|
10
|
+
- `download` uses modes: `--media-item`, `--library`, or `--from-file` / `-F`, plus selectors `--id` or `--search`.
|
|
11
|
+
- `play` uses `--id` or `--search` (no positional query; `--item-id` removed).
|
|
12
|
+
- `search` uses `--media-item` / `--library` plus exactly one of `--id`, `--search`, or `--all`.
|
|
13
|
+
- `search --count` / `-n` defaults to 30 for both `--media-item` and `--library`.
|
|
14
|
+
- `search --media-item --all` probes `TotalRecordCount` and refuses if more than `--count` items (asks to use `--search`).
|
|
15
|
+
- `--dry-run` / `-n` works for all `download` modes.
|
|
16
|
+
- `--pick-best-item` for item title resolution (`download --media-item --search`, `--from-file`, `play --search`); not with `--library`.
|
|
17
|
+
- `help`: list available commands with a short summary (no server required).
|
|
18
|
+
|
|
19
|
+
Added:
|
|
20
|
+
|
|
21
|
+
- Credential store: single kubeconfig-style `auth.json` (`contexts` + `current_context`); legacy `*.cache` migrated on first use.
|
|
22
|
+
- `login` / `logout` (logout calls `POST /Sessions/Logout` when possible and always clears the local context).
|
|
23
|
+
- `emby-cli config`: `current-server`, `get-servers`, `use-server`, `view` (tokens redacted).
|
|
24
|
+
- Without `--server` / `EMBY_SERVER`, operational commands use the active context server.
|
|
25
|
+
- Selectors for `search` / `download` / `play` validated before authenticating.
|
|
26
|
+
- `info`: Connection / Server / Content sections; libraries shown as count only.
|
|
27
|
+
- Silent username auth (no `Authenticating…` / `OK`); transparent re-login on HTTP 401 when password is known.
|
|
28
|
+
|
|
5
29
|
## 0.2.2
|
|
6
30
|
|
|
7
31
|
- `version`: show emby-cli version; with valid server credentials also show Emby `/System/Info` version.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: emby-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: CLI to list, search, play, and download/backup original media from an Emby server via its REST API.
|
|
5
5
|
Project-URL: Homepage, https://github.com/soukron/emby-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/soukron/emby-cli
|
|
@@ -37,7 +37,7 @@ Description-Content-Type: text/markdown
|
|
|
37
37
|
[PyPI](https://pypi.org/project/emby-cli/)
|
|
38
38
|
[License: CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/)
|
|
39
39
|
|
|
40
|
-
CLI to **
|
|
40
|
+
CLI to **search, play, and download/backup** original media files from an [Emby](https://emby.media/) server via its REST API. Only HTTP access is required (default port 8096) — no SSH or rsync.
|
|
41
41
|
|
|
42
42
|
## Install
|
|
43
43
|
|
|
@@ -58,34 +58,55 @@ Set environment variables (or pass CLI flags):
|
|
|
58
58
|
|
|
59
59
|
| Variable | Description |
|
|
60
60
|
|----------|-------------|
|
|
61
|
-
| `EMBY_SERVER` | Server URL (required) |
|
|
61
|
+
| `EMBY_SERVER` | Server URL (required for most commands) |
|
|
62
62
|
| `EMBY_API_KEY` | API key (or use username/password) |
|
|
63
63
|
| `EMBY_USERNAME` / `EMBY_PASSWORD` | Name/password auth |
|
|
64
64
|
| `EMBY_OUTPUT` | Download directory (default `./downloads`) |
|
|
65
65
|
| `EMBY_METHOD` | `download`, `stream`, or `hls` |
|
|
66
66
|
| `EMBY_PLAYER` | External player for `play` |
|
|
67
|
+
| `EMBY_ITEM_ID` | Default for `--id` (item mode / play) |
|
|
68
|
+
| `EMBY_CACHE_DIR` | Credentials directory (default `~/.cache/emby-cli`; file `auth.json`) |
|
|
69
|
+
| `EMBY_NO_AUTH_CACHE` | Set to `1` to disable reading/writing the AccessToken cache |
|
|
67
70
|
|
|
68
71
|
See `.env.example`. The app does not load `.env` itself — `source` it or export vars.
|
|
69
72
|
|
|
73
|
+
With username/password, sessions are stored in a single kubeconfig-style `auth.json` (`contexts` + `current_context`). `emby-cli login` saves a server entry and makes it active. Without `--server`, commands use the active server. Manage entries with `emby-cli config` (`current-server`, `get-servers`, `use-server`, `view`). API keys are not stored.
|
|
74
|
+
|
|
70
75
|
## Usage
|
|
71
76
|
|
|
72
77
|
```bash
|
|
78
|
+
emby-cli help
|
|
79
|
+
emby-cli login
|
|
80
|
+
emby-cli logout
|
|
81
|
+
emby-cli config get-servers
|
|
82
|
+
emby-cli config use-server 'user@http://emby:8096'
|
|
83
|
+
emby-cli config current-server
|
|
84
|
+
emby-cli config view
|
|
73
85
|
emby-cli version
|
|
74
86
|
emby-cli info
|
|
75
|
-
emby-cli
|
|
76
|
-
emby-cli search "Title"
|
|
77
|
-
emby-cli search
|
|
78
|
-
emby-cli
|
|
79
|
-
emby-cli
|
|
80
|
-
emby-cli
|
|
81
|
-
emby-cli
|
|
82
|
-
|
|
83
|
-
emby-cli
|
|
87
|
+
emby-cli search --media-item --search "Title"
|
|
88
|
+
emby-cli search --media-item --search "Title" --count 50
|
|
89
|
+
emby-cli search --media-item --id 123456
|
|
90
|
+
emby-cli search --media-item --all
|
|
91
|
+
emby-cli search --library --all
|
|
92
|
+
emby-cli search --library --id 614156
|
|
93
|
+
emby-cli search --library --search "PELICULAS"
|
|
94
|
+
|
|
95
|
+
emby-cli download --media-item --id 123456
|
|
96
|
+
emby-cli download --media-item --search "californication S01E01" --pick-best-item
|
|
97
|
+
emby-cli download --library --id 12345
|
|
98
|
+
emby-cli download --library --search "PELICULAS 4K"
|
|
99
|
+
emby-cli download --from-file titles.txt --dry-run
|
|
100
|
+
|
|
101
|
+
emby-cli play --id 123456
|
|
102
|
+
emby-cli play --search "Pelicula (1980)" --pick-best-item
|
|
84
103
|
```
|
|
85
104
|
|
|
86
105
|
`info` reports libraries and totals from `GET /Items/Counts` (totals may include multiple versions of the same title).
|
|
87
106
|
|
|
88
|
-
Title resolution (`play`
|
|
107
|
+
Title resolution (`play --search`, `download --media-item --search`, `download --from-file`) is **strict** by default (year mismatch or multiple matches fail). Pass `--pick-best-item` to auto-select the best ≤1080p version. With `--from-file` / `--media-item --search`, a season line like `Show S01` downloads the whole season (same pick-best rule per episode version). Library `--search` requires an exact name match (case-insensitive); no pick-best.
|
|
108
|
+
|
|
109
|
+
`--dry-run` / `-n` works for all `download` modes (no files written).
|
|
89
110
|
|
|
90
111
|
Download methods (`-m` / `EMBY_METHOD`):
|
|
91
112
|
|
emby_cli-0.3.0/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# emby-cli
|
|
2
|
+
|
|
3
|
+
[PyPI](https://pypi.org/project/emby-cli/)
|
|
4
|
+
[License: CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/)
|
|
5
|
+
|
|
6
|
+
CLI to **search, play, and download/backup** original media files from an [Emby](https://emby.media/) server via its REST API. Only HTTP access is required (default port 8096) — no SSH or rsync.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install emby-cli
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Development (from a clone of this repo):
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
18
|
+
pip install -e ".[dev]"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Configuration
|
|
22
|
+
|
|
23
|
+
Set environment variables (or pass CLI flags):
|
|
24
|
+
|
|
25
|
+
| Variable | Description |
|
|
26
|
+
|----------|-------------|
|
|
27
|
+
| `EMBY_SERVER` | Server URL (required for most commands) |
|
|
28
|
+
| `EMBY_API_KEY` | API key (or use username/password) |
|
|
29
|
+
| `EMBY_USERNAME` / `EMBY_PASSWORD` | Name/password auth |
|
|
30
|
+
| `EMBY_OUTPUT` | Download directory (default `./downloads`) |
|
|
31
|
+
| `EMBY_METHOD` | `download`, `stream`, or `hls` |
|
|
32
|
+
| `EMBY_PLAYER` | External player for `play` |
|
|
33
|
+
| `EMBY_ITEM_ID` | Default for `--id` (item mode / play) |
|
|
34
|
+
| `EMBY_CACHE_DIR` | Credentials directory (default `~/.cache/emby-cli`; file `auth.json`) |
|
|
35
|
+
| `EMBY_NO_AUTH_CACHE` | Set to `1` to disable reading/writing the AccessToken cache |
|
|
36
|
+
|
|
37
|
+
See `.env.example`. The app does not load `.env` itself — `source` it or export vars.
|
|
38
|
+
|
|
39
|
+
With username/password, sessions are stored in a single kubeconfig-style `auth.json` (`contexts` + `current_context`). `emby-cli login` saves a server entry and makes it active. Without `--server`, commands use the active server. Manage entries with `emby-cli config` (`current-server`, `get-servers`, `use-server`, `view`). API keys are not stored.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
emby-cli help
|
|
45
|
+
emby-cli login
|
|
46
|
+
emby-cli logout
|
|
47
|
+
emby-cli config get-servers
|
|
48
|
+
emby-cli config use-server 'user@http://emby:8096'
|
|
49
|
+
emby-cli config current-server
|
|
50
|
+
emby-cli config view
|
|
51
|
+
emby-cli version
|
|
52
|
+
emby-cli info
|
|
53
|
+
emby-cli search --media-item --search "Title"
|
|
54
|
+
emby-cli search --media-item --search "Title" --count 50
|
|
55
|
+
emby-cli search --media-item --id 123456
|
|
56
|
+
emby-cli search --media-item --all
|
|
57
|
+
emby-cli search --library --all
|
|
58
|
+
emby-cli search --library --id 614156
|
|
59
|
+
emby-cli search --library --search "PELICULAS"
|
|
60
|
+
|
|
61
|
+
emby-cli download --media-item --id 123456
|
|
62
|
+
emby-cli download --media-item --search "californication S01E01" --pick-best-item
|
|
63
|
+
emby-cli download --library --id 12345
|
|
64
|
+
emby-cli download --library --search "PELICULAS 4K"
|
|
65
|
+
emby-cli download --from-file titles.txt --dry-run
|
|
66
|
+
|
|
67
|
+
emby-cli play --id 123456
|
|
68
|
+
emby-cli play --search "Pelicula (1980)" --pick-best-item
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`info` reports libraries and totals from `GET /Items/Counts` (totals may include multiple versions of the same title).
|
|
72
|
+
|
|
73
|
+
Title resolution (`play --search`, `download --media-item --search`, `download --from-file`) is **strict** by default (year mismatch or multiple matches fail). Pass `--pick-best-item` to auto-select the best ≤1080p version. With `--from-file` / `--media-item --search`, a season line like `Show S01` downloads the whole season (same pick-best rule per episode version). Library `--search` requires an exact name match (case-insensitive); no pick-best.
|
|
74
|
+
|
|
75
|
+
`--dry-run` / `-n` works for all `download` modes (no files written).
|
|
76
|
+
|
|
77
|
+
Download methods (`-m` / `EMBY_METHOD`):
|
|
78
|
+
|
|
79
|
+
- `download` — `GET /Items/{id}/Download`
|
|
80
|
+
- `stream` — DirectStreamUrl / `original.*` (Emby Web style)
|
|
81
|
+
- `hls` — HLS segments remuxed to `.mkv` (needs `static-ffmpeg`)
|
|
82
|
+
|
|
83
|
+
## Development
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pip install -e ".[dev]"
|
|
87
|
+
pytest -q
|
|
88
|
+
python -m build && twine check dist/*
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
To publish a release, tag `vX.Y.Z` and push it (or create a GitHub Release). CI builds the package and publishes to PyPI via Trusted Publisher.
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
[CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/)
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.3.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 3, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Persist Emby AccessToken sessions in a single kubeconfig-style store."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import asdict, dataclass, field
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class AuthCacheEntry:
|
|
14
|
+
server_url: str
|
|
15
|
+
username: str
|
|
16
|
+
access_token: str
|
|
17
|
+
user_id: str
|
|
18
|
+
saved_at: str
|
|
19
|
+
name: str = ""
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
self.server_url = self.server_url.rstrip("/")
|
|
23
|
+
if not self.name:
|
|
24
|
+
self.name = context_name(self.server_url, self.username)
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def create(
|
|
28
|
+
cls,
|
|
29
|
+
server_url: str,
|
|
30
|
+
username: str,
|
|
31
|
+
access_token: str,
|
|
32
|
+
user_id: str,
|
|
33
|
+
) -> AuthCacheEntry:
|
|
34
|
+
return cls(
|
|
35
|
+
server_url=server_url.rstrip("/"),
|
|
36
|
+
username=username,
|
|
37
|
+
access_token=access_token,
|
|
38
|
+
user_id=user_id,
|
|
39
|
+
saved_at=datetime.now(timezone.utc).isoformat(),
|
|
40
|
+
name=context_name(server_url, username),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class AuthStore:
|
|
46
|
+
current_context: str = ""
|
|
47
|
+
contexts: list[AuthCacheEntry] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def context_name(server_url: str, username: str) -> str:
|
|
51
|
+
return f"{username}@{server_url.rstrip('/')}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cache_dir() -> Path:
|
|
55
|
+
return Path(os.environ.get("EMBY_CACHE_DIR", Path.home() / ".cache" / "emby-cli"))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def auth_store_path() -> Path:
|
|
59
|
+
return cache_dir() / "auth.json"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _auth_cache_disabled() -> bool:
|
|
63
|
+
return os.environ.get("EMBY_NO_AUTH_CACHE") == "1"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _entry_from_dict(data: dict) -> AuthCacheEntry | None:
|
|
67
|
+
try:
|
|
68
|
+
entry = AuthCacheEntry(
|
|
69
|
+
server_url=str(data["server_url"]),
|
|
70
|
+
username=str(data["username"]),
|
|
71
|
+
access_token=str(data["access_token"]),
|
|
72
|
+
user_id=str(data["user_id"]),
|
|
73
|
+
saved_at=str(data.get("saved_at") or ""),
|
|
74
|
+
name=str(data.get("name") or ""),
|
|
75
|
+
)
|
|
76
|
+
except (KeyError, TypeError, ValueError):
|
|
77
|
+
return None
|
|
78
|
+
if not entry.access_token or not entry.user_id or not entry.server_url:
|
|
79
|
+
return None
|
|
80
|
+
if not entry.saved_at:
|
|
81
|
+
entry.saved_at = datetime.now(timezone.utc).isoformat()
|
|
82
|
+
return entry
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse_legacy_cache_file(path: Path) -> AuthCacheEntry | None:
|
|
86
|
+
if not path.is_file():
|
|
87
|
+
return None
|
|
88
|
+
try:
|
|
89
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
90
|
+
except (OSError, json.JSONDecodeError, TypeError, ValueError):
|
|
91
|
+
return None
|
|
92
|
+
if not isinstance(data, dict):
|
|
93
|
+
return None
|
|
94
|
+
return _entry_from_dict(data)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _migrate_legacy_cache_files() -> AuthStore | None:
|
|
98
|
+
"""Import ``*.cache`` files into a new store; delete them on success."""
|
|
99
|
+
root = cache_dir()
|
|
100
|
+
if not root.is_dir():
|
|
101
|
+
return None
|
|
102
|
+
legacy = sorted(root.glob("*.cache"))
|
|
103
|
+
if not legacy:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
entries: list[AuthCacheEntry] = []
|
|
107
|
+
for path in legacy:
|
|
108
|
+
entry = _parse_legacy_cache_file(path)
|
|
109
|
+
if entry is not None:
|
|
110
|
+
entries.append(entry)
|
|
111
|
+
if not entries:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
by_name: dict[str, AuthCacheEntry] = {}
|
|
115
|
+
for entry in entries:
|
|
116
|
+
prev = by_name.get(entry.name)
|
|
117
|
+
if prev is None or entry.saved_at >= prev.saved_at:
|
|
118
|
+
by_name[entry.name] = entry
|
|
119
|
+
merged = list(by_name.values())
|
|
120
|
+
merged.sort(key=lambda e: e.saved_at, reverse=True)
|
|
121
|
+
store = AuthStore(current_context=merged[0].name, contexts=merged)
|
|
122
|
+
save_store(store)
|
|
123
|
+
for path in legacy:
|
|
124
|
+
try:
|
|
125
|
+
path.unlink()
|
|
126
|
+
except OSError:
|
|
127
|
+
pass
|
|
128
|
+
return store
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def load_store() -> AuthStore:
|
|
132
|
+
"""Load ``auth.json``, migrating legacy ``*.cache`` files if needed."""
|
|
133
|
+
if _auth_cache_disabled():
|
|
134
|
+
return AuthStore()
|
|
135
|
+
|
|
136
|
+
path = auth_store_path()
|
|
137
|
+
if path.is_file():
|
|
138
|
+
try:
|
|
139
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
140
|
+
except (OSError, json.JSONDecodeError, TypeError, ValueError):
|
|
141
|
+
return AuthStore()
|
|
142
|
+
contexts: list[AuthCacheEntry] = []
|
|
143
|
+
for item in data.get("contexts") or []:
|
|
144
|
+
if isinstance(item, dict):
|
|
145
|
+
entry = _entry_from_dict(item)
|
|
146
|
+
if entry is not None:
|
|
147
|
+
contexts.append(entry)
|
|
148
|
+
current = str(data.get("current_context") or "")
|
|
149
|
+
if current and not any(c.name == current for c in contexts):
|
|
150
|
+
current = contexts[0].name if contexts else ""
|
|
151
|
+
elif not current and contexts:
|
|
152
|
+
current = max(contexts, key=lambda e: e.saved_at).name
|
|
153
|
+
return AuthStore(current_context=current, contexts=contexts)
|
|
154
|
+
|
|
155
|
+
migrated = _migrate_legacy_cache_files()
|
|
156
|
+
return migrated if migrated is not None else AuthStore()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def save_store(store: AuthStore) -> None:
|
|
160
|
+
if _auth_cache_disabled():
|
|
161
|
+
return
|
|
162
|
+
path = auth_store_path()
|
|
163
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
payload = {
|
|
165
|
+
"current_context": store.current_context,
|
|
166
|
+
"contexts": [asdict(c) for c in store.contexts],
|
|
167
|
+
}
|
|
168
|
+
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
169
|
+
path.chmod(0o600)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def list_contexts() -> list[AuthCacheEntry]:
|
|
173
|
+
store = load_store()
|
|
174
|
+
return sorted(store.contexts, key=lambda e: (e.server_url, e.username))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def get_active_entry() -> AuthCacheEntry | None:
|
|
178
|
+
store = load_store()
|
|
179
|
+
if not store.current_context:
|
|
180
|
+
return None
|
|
181
|
+
for entry in store.contexts:
|
|
182
|
+
if entry.name == store.current_context:
|
|
183
|
+
return entry
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def upsert_context(entry: AuthCacheEntry, *, activate: bool = True) -> None:
|
|
188
|
+
if _auth_cache_disabled():
|
|
189
|
+
return
|
|
190
|
+
store = load_store()
|
|
191
|
+
entry = AuthCacheEntry(
|
|
192
|
+
server_url=entry.server_url,
|
|
193
|
+
username=entry.username,
|
|
194
|
+
access_token=entry.access_token,
|
|
195
|
+
user_id=entry.user_id,
|
|
196
|
+
saved_at=entry.saved_at or datetime.now(timezone.utc).isoformat(),
|
|
197
|
+
name=entry.name or context_name(entry.server_url, entry.username),
|
|
198
|
+
)
|
|
199
|
+
store.contexts = [c for c in store.contexts if c.name != entry.name]
|
|
200
|
+
store.contexts.append(entry)
|
|
201
|
+
if activate:
|
|
202
|
+
store.current_context = entry.name
|
|
203
|
+
save_store(store)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def remove_context(
|
|
207
|
+
*,
|
|
208
|
+
name: str | None = None,
|
|
209
|
+
server_url: str | None = None,
|
|
210
|
+
username: str | None = None,
|
|
211
|
+
) -> bool:
|
|
212
|
+
"""Remove a context. Returns True if something was removed."""
|
|
213
|
+
if _auth_cache_disabled():
|
|
214
|
+
return False
|
|
215
|
+
store = load_store()
|
|
216
|
+
if name is None and server_url is not None and username is not None:
|
|
217
|
+
name = context_name(server_url, username)
|
|
218
|
+
if not name:
|
|
219
|
+
return False
|
|
220
|
+
before = len(store.contexts)
|
|
221
|
+
store.contexts = [c for c in store.contexts if c.name != name]
|
|
222
|
+
if len(store.contexts) == before:
|
|
223
|
+
return False
|
|
224
|
+
if store.current_context == name:
|
|
225
|
+
if store.contexts:
|
|
226
|
+
store.current_context = max(
|
|
227
|
+
store.contexts, key=lambda e: e.saved_at
|
|
228
|
+
).name
|
|
229
|
+
else:
|
|
230
|
+
store.current_context = ""
|
|
231
|
+
save_store(store)
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def set_current_context(name: str) -> AuthCacheEntry:
|
|
236
|
+
"""Set ``current_context`` by exact name or unique server URL match."""
|
|
237
|
+
store = load_store()
|
|
238
|
+
needle = name.strip()
|
|
239
|
+
matches = [c for c in store.contexts if c.name == needle]
|
|
240
|
+
if not matches:
|
|
241
|
+
# Unique match by server_url or username@server fragment.
|
|
242
|
+
by_server = [
|
|
243
|
+
c for c in store.contexts
|
|
244
|
+
if c.server_url == needle.rstrip("/") or c.name.endswith(f"@{needle.rstrip('/')}")
|
|
245
|
+
]
|
|
246
|
+
if len(by_server) == 1:
|
|
247
|
+
matches = by_server
|
|
248
|
+
else:
|
|
249
|
+
by_user_server = [
|
|
250
|
+
c for c in store.contexts
|
|
251
|
+
if c.name == needle or c.username == needle
|
|
252
|
+
]
|
|
253
|
+
if len(by_user_server) == 1:
|
|
254
|
+
matches = by_user_server
|
|
255
|
+
if len(matches) != 1:
|
|
256
|
+
raise KeyError(f"context '{name}' not found")
|
|
257
|
+
entry = matches[0]
|
|
258
|
+
store.current_context = entry.name
|
|
259
|
+
save_store(store)
|
|
260
|
+
return entry
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def load_auth_cache(
|
|
264
|
+
*,
|
|
265
|
+
server_url: str,
|
|
266
|
+
username: str | None = None,
|
|
267
|
+
cache_file: Path | None = None,
|
|
268
|
+
) -> AuthCacheEntry | None:
|
|
269
|
+
"""Load a session for *server_url* (optional *username*).
|
|
270
|
+
|
|
271
|
+
*cache_file* is ignored (legacy API); the unified store is always used.
|
|
272
|
+
"""
|
|
273
|
+
del cache_file # legacy kwarg
|
|
274
|
+
if _auth_cache_disabled():
|
|
275
|
+
return None
|
|
276
|
+
store = load_store()
|
|
277
|
+
server = server_url.rstrip("/")
|
|
278
|
+
matches = [c for c in store.contexts if c.server_url == server]
|
|
279
|
+
if username is not None:
|
|
280
|
+
matches = [c for c in matches if c.username == username]
|
|
281
|
+
if not matches:
|
|
282
|
+
return None
|
|
283
|
+
if username is not None:
|
|
284
|
+
return matches[0]
|
|
285
|
+
# Prefer active context if it matches this server.
|
|
286
|
+
for c in matches:
|
|
287
|
+
if c.name == store.current_context:
|
|
288
|
+
return c
|
|
289
|
+
return max(matches, key=lambda e: e.saved_at)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def save_auth_cache(
|
|
293
|
+
entry: AuthCacheEntry,
|
|
294
|
+
cache_file: Path | None = None,
|
|
295
|
+
) -> None:
|
|
296
|
+
del cache_file
|
|
297
|
+
upsert_context(entry, activate=True)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def clear_auth_cache(
|
|
301
|
+
*,
|
|
302
|
+
server_url: str | None = None,
|
|
303
|
+
username: str | None = None,
|
|
304
|
+
cache_file: Path | None = None,
|
|
305
|
+
) -> None:
|
|
306
|
+
del cache_file
|
|
307
|
+
if server_url is not None and username is not None:
|
|
308
|
+
remove_context(server_url=server_url, username=username)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def list_auth_cache_entries() -> list[AuthCacheEntry]:
|
|
312
|
+
return list_contexts()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# Back-compat aliases used by older tests / docs.
|
|
316
|
+
def auth_cache_file(server_url: str, username: str) -> Path:
|
|
317
|
+
"""Deprecated path helper; returns store path (not per-user files)."""
|
|
318
|
+
del server_url, username
|
|
319
|
+
return auth_store_path()
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def unique_cached_server_urls() -> list[str]:
|
|
323
|
+
return sorted({e.server_url for e in list_auth_cache_entries()})
|