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.
Files changed (56) hide show
  1. {emby_cli-0.2.2 → emby_cli-0.3.0}/.env.example +2 -1
  2. {emby_cli-0.2.2 → emby_cli-0.3.0}/CHANGELOG.md +24 -0
  3. {emby_cli-0.2.2 → emby_cli-0.3.0}/PKG-INFO +34 -13
  4. emby_cli-0.3.0/README.md +95 -0
  5. {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/_version.py +2 -2
  6. emby_cli-0.3.0/src/emby_cli/auth_cache.py +323 -0
  7. emby_cli-0.3.0/src/emby_cli/cli.py +301 -0
  8. {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/client.py +149 -15
  9. emby_cli-0.3.0/src/emby_cli/commands/config.py +102 -0
  10. emby_cli-0.3.0/src/emby_cli/commands/download.py +348 -0
  11. emby_cli-0.3.0/src/emby_cli/commands/help.py +27 -0
  12. {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/commands/info.py +23 -19
  13. emby_cli-0.3.0/src/emby_cli/commands/login.py +29 -0
  14. emby_cli-0.3.0/src/emby_cli/commands/logout.py +73 -0
  15. {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/commands/play.py +25 -15
  16. emby_cli-0.3.0/src/emby_cli/commands/search.py +139 -0
  17. {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/commands/version.py +7 -8
  18. {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/constants.py +3 -0
  19. emby_cli-0.3.0/src/emby_cli/credentials.py +115 -0
  20. {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/download_ops.py +38 -0
  21. {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/resolve.py +5 -5
  22. emby_cli-0.3.0/tests/test_auth_cache.py +152 -0
  23. emby_cli-0.3.0/tests/test_config.py +73 -0
  24. emby_cli-0.3.0/tests/test_credentials.py +125 -0
  25. emby_cli-0.3.0/tests/test_download_ops.py +33 -0
  26. {emby_cli-0.2.2 → emby_cli-0.3.0}/tests/test_info.py +10 -2
  27. emby_cli-0.3.0/tests/test_logout.py +90 -0
  28. emby_cli-0.3.0/tests/test_parser.py +207 -0
  29. emby_cli-0.3.0/tests/test_search.py +52 -0
  30. emby_cli-0.3.0/tests/test_session_auth.py +123 -0
  31. emby_cli-0.3.0/tests/test_validate_args.py +156 -0
  32. {emby_cli-0.2.2 → emby_cli-0.3.0}/tests/test_version.py +5 -2
  33. emby_cli-0.2.2/README.md +0 -74
  34. emby_cli-0.2.2/src/emby_cli/cli.py +0 -167
  35. emby_cli-0.2.2/src/emby_cli/commands/batch.py +0 -158
  36. emby_cli-0.2.2/src/emby_cli/commands/download.py +0 -71
  37. emby_cli-0.2.2/src/emby_cli/commands/list.py +0 -50
  38. emby_cli-0.2.2/src/emby_cli/commands/search.py +0 -30
  39. emby_cli-0.2.2/src/emby_cli/commands/sync.py +0 -46
  40. emby_cli-0.2.2/tests/test_parser.py +0 -80
  41. {emby_cli-0.2.2 → emby_cli-0.3.0}/.github/workflows/ci.yml +0 -0
  42. {emby_cli-0.2.2 → emby_cli-0.3.0}/.github/workflows/publish.yml +0 -0
  43. {emby_cli-0.2.2 → emby_cli-0.3.0}/.gitignore +0 -0
  44. {emby_cli-0.2.2 → emby_cli-0.3.0}/LICENSE +0 -0
  45. {emby_cli-0.2.2 → emby_cli-0.3.0}/pyproject.toml +0 -0
  46. {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/__init__.py +0 -0
  47. {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/__main__.py +0 -0
  48. {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/commands/__init__.py +0 -0
  49. {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/output.py +0 -0
  50. {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/util.py +0 -0
  51. {emby_cli-0.2.2 → emby_cli-0.3.0}/src/emby_cli/version.py +0 -0
  52. {emby_cli-0.2.2 → emby_cli-0.3.0}/tests/test_client.py +0 -0
  53. {emby_cli-0.2.2 → emby_cli-0.3.0}/tests/test_output.py +0 -0
  54. {emby_cli-0.2.2 → emby_cli-0.3.0}/tests/test_resolve.py +0 -0
  55. {emby_cli-0.2.2 → emby_cli-0.3.0}/tests/test_resolve_table.py +0 -0
  56. {emby_cli-0.2.2 → emby_cli-0.3.0}/tests/test_util.py +0 -0
@@ -9,6 +9,7 @@ EMBY_PASSWORD=
9
9
  # Optional defaults
10
10
  # EMBY_OUTPUT=./downloads
11
11
  # EMBY_ITEM_ID=
12
- # EMBY_LIBRARY=
13
12
  # EMBY_METHOD=download
14
13
  # EMBY_PLAYER=vlc
14
+ # EMBY_CACHE_DIR=
15
+ # EMBY_NO_AUTH_CACHE=
@@ -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.2.2
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 **list, 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.
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 list
76
- emby-cli search "Title"
77
- emby-cli search "Title" --count 50
78
- emby-cli download -i <itemId> -o ./downloads
79
- emby-cli sync -l "Movies"
80
- emby-cli batch -F titles.txt -n
81
- emby-cli batch -F titles.txt --pick-best-item
82
- emby-cli play "Movie (2010)"
83
- emby-cli play "Show S01E01" --pick-best-item
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` / `batch`) is **strict** by default (year mismatch or multiple matches fail that line). Pass `--pick-best-item` to auto-select the best ≤1080p version. In `batch`, a season line like `Show S01` downloads the whole season (with the same pick-best rule per episode version).
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
 
@@ -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.2.2'
22
- __version_tuple__ = version_tuple = (0, 2, 2)
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()})