cli-web-pexels 0.1.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.
- cli_web_pexels-0.1.0/PKG-INFO +11 -0
- cli_web_pexels-0.1.0/cli_web/pexels/README.md +83 -0
- cli_web_pexels-0.1.0/cli_web/pexels/__init__.py +3 -0
- cli_web_pexels-0.1.0/cli_web/pexels/__main__.py +6 -0
- cli_web_pexels-0.1.0/cli_web/pexels/commands/__init__.py +0 -0
- cli_web_pexels-0.1.0/cli_web/pexels/commands/collections.py +63 -0
- cli_web_pexels-0.1.0/cli_web/pexels/commands/photos.py +116 -0
- cli_web_pexels-0.1.0/cli_web/pexels/commands/users.py +62 -0
- cli_web_pexels-0.1.0/cli_web/pexels/commands/videos.py +162 -0
- cli_web_pexels-0.1.0/cli_web/pexels/core/__init__.py +0 -0
- cli_web_pexels-0.1.0/cli_web/pexels/core/client.py +299 -0
- cli_web_pexels-0.1.0/cli_web/pexels/core/exceptions.py +54 -0
- cli_web_pexels-0.1.0/cli_web/pexels/core/models.py +213 -0
- cli_web_pexels-0.1.0/cli_web/pexels/pexels_cli.py +139 -0
- cli_web_pexels-0.1.0/cli_web/pexels/skills/SKILL.md +105 -0
- cli_web_pexels-0.1.0/cli_web/pexels/tests/TEST.md +130 -0
- cli_web_pexels-0.1.0/cli_web/pexels/tests/__init__.py +0 -0
- cli_web_pexels-0.1.0/cli_web/pexels/tests/test_core.py +326 -0
- cli_web_pexels-0.1.0/cli_web/pexels/tests/test_e2e.py +168 -0
- cli_web_pexels-0.1.0/cli_web/pexels/utils/__init__.py +0 -0
- cli_web_pexels-0.1.0/cli_web/pexels/utils/doctor.py +188 -0
- cli_web_pexels-0.1.0/cli_web/pexels/utils/helpers.py +42 -0
- cli_web_pexels-0.1.0/cli_web/pexels/utils/mcp_server.py +290 -0
- cli_web_pexels-0.1.0/cli_web/pexels/utils/output.py +139 -0
- cli_web_pexels-0.1.0/cli_web/pexels/utils/repl_skin.py +486 -0
- cli_web_pexels-0.1.0/cli_web_pexels.egg-info/PKG-INFO +11 -0
- cli_web_pexels-0.1.0/cli_web_pexels.egg-info/SOURCES.txt +31 -0
- cli_web_pexels-0.1.0/cli_web_pexels.egg-info/dependency_links.txt +1 -0
- cli_web_pexels-0.1.0/cli_web_pexels.egg-info/entry_points.txt +2 -0
- cli_web_pexels-0.1.0/cli_web_pexels.egg-info/requires.txt +3 -0
- cli_web_pexels-0.1.0/cli_web_pexels.egg-info/top_level.txt +1 -0
- cli_web_pexels-0.1.0/setup.cfg +4 -0
- cli_web_pexels-0.1.0/setup.py +24 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cli-web-pexels
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for Pexels free stock photos and videos
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: click>=8.0
|
|
7
|
+
Requires-Dist: curl_cffi>=0.5
|
|
8
|
+
Requires-Dist: prompt_toolkit>=3.0
|
|
9
|
+
Dynamic: requires-dist
|
|
10
|
+
Dynamic: requires-python
|
|
11
|
+
Dynamic: summary
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# cli-web-pexels
|
|
2
|
+
|
|
3
|
+
CLI for [Pexels](https://www.pexels.com/) — free stock photos and videos from the command line.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cd pexels/agent-harness
|
|
9
|
+
pip install -e .
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
### Search Photos
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
cli-web-pexels photos search "nature"
|
|
18
|
+
cli-web-pexels photos search "sunset" --orientation landscape --size large --json
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Get Photo Details
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
cli-web-pexels photos get green-leaves-1072179
|
|
25
|
+
cli-web-pexels photos get green-leaves-1072179 --json
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Download Photos
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
cli-web-pexels photos download green-leaves-1072179
|
|
32
|
+
cli-web-pexels photos download green-leaves-1072179 --size large --output photo.jpg
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Search Videos
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
cli-web-pexels videos search "ocean"
|
|
39
|
+
cli-web-pexels videos search "nature" --orientation landscape --json
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Download Videos
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
cli-web-pexels videos download long-narrow-road-856479 --quality hd
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### User Profiles
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
cli-web-pexels users get pixabay
|
|
52
|
+
cli-web-pexels users media pixabay --json
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Collections
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
cli-web-pexels collections discover
|
|
59
|
+
cli-web-pexels collections get spring-aesthetic-fvku5ng --json
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### REPL Mode
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
cli-web-pexels # enters interactive REPL
|
|
66
|
+
cli-web-pexels --json # REPL with JSON output
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Search Filters
|
|
70
|
+
|
|
71
|
+
| Filter | Options | Example |
|
|
72
|
+
|--------|---------|---------|
|
|
73
|
+
| `--orientation` | landscape, portrait, square | `--orientation landscape` |
|
|
74
|
+
| `--size` | large, medium, small | `--size large` |
|
|
75
|
+
| `--color` | hex or named color | `--color orange` |
|
|
76
|
+
| `--page` | page number (1-based) | `--page 2` |
|
|
77
|
+
|
|
78
|
+
## Running Tests
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
cd pexels/agent-harness
|
|
82
|
+
python -m pytest cli_web/pexels/tests/ -v
|
|
83
|
+
```
|
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Collections commands for cli-web-pexels."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from ..core.client import PexelsClient
|
|
6
|
+
from ..utils.helpers import handle_errors
|
|
7
|
+
from ..utils.output import (
|
|
8
|
+
print_collection_detail,
|
|
9
|
+
print_collections_table,
|
|
10
|
+
print_json,
|
|
11
|
+
print_pagination,
|
|
12
|
+
print_photos_table,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group("collections")
|
|
17
|
+
@click.pass_context
|
|
18
|
+
def collections(ctx):
|
|
19
|
+
"""Browse and explore Pexels collections."""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@collections.command()
|
|
24
|
+
@click.argument("slug")
|
|
25
|
+
@click.option("--page", type=int, default=1, help="Page number.")
|
|
26
|
+
@click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
|
|
27
|
+
@click.pass_context
|
|
28
|
+
def get(ctx, slug, page, json_mode):
|
|
29
|
+
"""Get collection detail and media by slug."""
|
|
30
|
+
json_mode = json_mode or ctx.obj.get("json", False)
|
|
31
|
+
with handle_errors(json_mode):
|
|
32
|
+
client = PexelsClient()
|
|
33
|
+
result = client.get_collection(slug, page=page)
|
|
34
|
+
if json_mode:
|
|
35
|
+
print_json(result)
|
|
36
|
+
else:
|
|
37
|
+
print_collection_detail(result["collection"])
|
|
38
|
+
media = result.get("media", {})
|
|
39
|
+
if media.get("data"):
|
|
40
|
+
print_photos_table(media["data"])
|
|
41
|
+
print_pagination(media.get("pagination", {}))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@collections.command()
|
|
45
|
+
@click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
|
|
46
|
+
@click.pass_context
|
|
47
|
+
def discover(ctx, json_mode):
|
|
48
|
+
"""Show popular and curated collections from the discover page."""
|
|
49
|
+
json_mode = json_mode or ctx.obj.get("json", False)
|
|
50
|
+
with handle_errors(json_mode):
|
|
51
|
+
client = PexelsClient()
|
|
52
|
+
result = client.discover()
|
|
53
|
+
if json_mode:
|
|
54
|
+
print_json(result)
|
|
55
|
+
else:
|
|
56
|
+
if result.get("popular"):
|
|
57
|
+
click.echo("\n Popular Collections")
|
|
58
|
+
click.echo(f" {'─' * 40}")
|
|
59
|
+
print_collections_table(result["popular"])
|
|
60
|
+
if result.get("collections"):
|
|
61
|
+
click.echo("\n Curated Collections")
|
|
62
|
+
click.echo(f" {'─' * 40}")
|
|
63
|
+
print_collections_table(result["collections"])
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Photos commands for cli-web-pexels."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from ..core.client import PexelsClient
|
|
8
|
+
from ..core.exceptions import NotFoundError
|
|
9
|
+
from ..utils.helpers import handle_errors, sanitize_filename
|
|
10
|
+
from ..utils.output import (
|
|
11
|
+
print_json,
|
|
12
|
+
print_pagination,
|
|
13
|
+
print_photo_detail,
|
|
14
|
+
print_photos_table,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.group("photos")
|
|
19
|
+
@click.pass_context
|
|
20
|
+
def photos(ctx):
|
|
21
|
+
"""Browse and download Pexels photos."""
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@photos.command()
|
|
26
|
+
@click.argument("query")
|
|
27
|
+
@click.option(
|
|
28
|
+
"--orientation",
|
|
29
|
+
type=click.Choice(["landscape", "portrait", "square"]),
|
|
30
|
+
default=None,
|
|
31
|
+
help="Filter by orientation.",
|
|
32
|
+
)
|
|
33
|
+
@click.option(
|
|
34
|
+
"--size",
|
|
35
|
+
type=click.Choice(["large", "medium", "small"]),
|
|
36
|
+
default=None,
|
|
37
|
+
help="Filter by minimum size.",
|
|
38
|
+
)
|
|
39
|
+
@click.option("--color", default=None, help="Filter by color (hex or named color).")
|
|
40
|
+
@click.option("--page", type=int, default=1, help="Page number.")
|
|
41
|
+
@click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
|
|
42
|
+
@click.pass_context
|
|
43
|
+
def search(ctx, query, orientation, size, color, page, json_mode):
|
|
44
|
+
"""Search photos by keyword."""
|
|
45
|
+
json_mode = json_mode or ctx.obj.get("json", False)
|
|
46
|
+
with handle_errors(json_mode):
|
|
47
|
+
client = PexelsClient()
|
|
48
|
+
result = client.search_photos(
|
|
49
|
+
query=query,
|
|
50
|
+
orientation=orientation,
|
|
51
|
+
size=size,
|
|
52
|
+
color=color,
|
|
53
|
+
page=page,
|
|
54
|
+
)
|
|
55
|
+
if json_mode:
|
|
56
|
+
print_json(result)
|
|
57
|
+
else:
|
|
58
|
+
print_photos_table(result["data"])
|
|
59
|
+
print_pagination(result.get("pagination", {}))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@photos.command()
|
|
63
|
+
@click.argument("slug")
|
|
64
|
+
@click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
|
|
65
|
+
@click.pass_context
|
|
66
|
+
def get(ctx, slug, json_mode):
|
|
67
|
+
"""Get photo details by slug or ID."""
|
|
68
|
+
json_mode = json_mode or ctx.obj.get("json", False)
|
|
69
|
+
with handle_errors(json_mode):
|
|
70
|
+
client = PexelsClient()
|
|
71
|
+
photo = client.get_photo(slug)
|
|
72
|
+
if json_mode:
|
|
73
|
+
print_json(photo)
|
|
74
|
+
else:
|
|
75
|
+
print_photo_detail(photo)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@photos.command()
|
|
79
|
+
@click.argument("slug")
|
|
80
|
+
@click.option("--output", "-o", default=None, help="Output file path.")
|
|
81
|
+
@click.option(
|
|
82
|
+
"--size",
|
|
83
|
+
type=click.Choice(["small", "medium", "large", "original"]),
|
|
84
|
+
default="original",
|
|
85
|
+
help="Download size.",
|
|
86
|
+
)
|
|
87
|
+
@click.pass_context
|
|
88
|
+
def download(ctx, slug, output, size):
|
|
89
|
+
"""Download a photo by slug or ID."""
|
|
90
|
+
json_mode = ctx.obj.get("json", False)
|
|
91
|
+
with handle_errors(json_mode):
|
|
92
|
+
client = PexelsClient()
|
|
93
|
+
photo = client.get_photo(slug)
|
|
94
|
+
|
|
95
|
+
image = photo.get("image", {})
|
|
96
|
+
size_map = {
|
|
97
|
+
"original": "download",
|
|
98
|
+
"large": "large",
|
|
99
|
+
"medium": "medium",
|
|
100
|
+
"small": "small",
|
|
101
|
+
}
|
|
102
|
+
url = image.get(size_map[size]) or image.get("download") or image.get("large")
|
|
103
|
+
if not url:
|
|
104
|
+
raise NotFoundError("No download URL available")
|
|
105
|
+
|
|
106
|
+
if output is None:
|
|
107
|
+
title = photo.get("title") or photo.get("alt") or "photo"
|
|
108
|
+
ext = os.path.splitext(url.split("?")[0])[1] or ".jpeg"
|
|
109
|
+
output = sanitize_filename(title) + ext
|
|
110
|
+
|
|
111
|
+
client.download_file(url, output)
|
|
112
|
+
|
|
113
|
+
if json_mode:
|
|
114
|
+
print_json({"downloaded": True, "path": output, "size": size})
|
|
115
|
+
else:
|
|
116
|
+
click.echo(f"Downloaded: {output}")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Users commands for cli-web-pexels."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from ..core.client import PexelsClient
|
|
6
|
+
from ..utils.helpers import handle_errors
|
|
7
|
+
from ..utils.output import print_json, print_pagination, print_user_detail
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group("users")
|
|
11
|
+
@click.pass_context
|
|
12
|
+
def users(ctx):
|
|
13
|
+
"""Browse user profiles and their media."""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@users.command("get")
|
|
18
|
+
@click.argument("username")
|
|
19
|
+
@click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
|
|
20
|
+
@click.pass_context
|
|
21
|
+
def get(ctx, username, json_mode):
|
|
22
|
+
"""Get a user's profile information."""
|
|
23
|
+
json_mode = json_mode or ctx.obj.get("json", False)
|
|
24
|
+
with handle_errors(json_mode):
|
|
25
|
+
client = PexelsClient()
|
|
26
|
+
result = client.get_user(username)
|
|
27
|
+
if json_mode:
|
|
28
|
+
print_json(result)
|
|
29
|
+
else:
|
|
30
|
+
print_user_detail(result["user"])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@users.command("media")
|
|
34
|
+
@click.argument("username")
|
|
35
|
+
@click.option("--page", type=int, default=1, help="Page number.")
|
|
36
|
+
@click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
|
|
37
|
+
@click.pass_context
|
|
38
|
+
def media(ctx, username, page, json_mode):
|
|
39
|
+
"""List a user's uploaded photos and videos."""
|
|
40
|
+
json_mode = json_mode or ctx.obj.get("json", False)
|
|
41
|
+
with handle_errors(json_mode):
|
|
42
|
+
client = PexelsClient()
|
|
43
|
+
result = client.get_user_media(username, page=page)
|
|
44
|
+
if json_mode:
|
|
45
|
+
print_json(result)
|
|
46
|
+
else:
|
|
47
|
+
items = result.get("data", [])
|
|
48
|
+
if not items:
|
|
49
|
+
click.echo(f" No media found for @{username}.")
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
click.echo(f"\n Media by @{username}")
|
|
53
|
+
click.echo(f" {'ID':<12} {'Type':<8} {'Title':<35} {'Size':<12}")
|
|
54
|
+
click.echo(f" {'─' * 12} {'─' * 8} {'─' * 35} {'─' * 12}")
|
|
55
|
+
for item in items:
|
|
56
|
+
title = (item.get("title") or "Untitled")[:34]
|
|
57
|
+
size = f"{item.get('width', '?')}x{item.get('height', '?')}"
|
|
58
|
+
click.echo(
|
|
59
|
+
f" {item.get('id', ''):<12} {item.get('type', 'photo'):<8} "
|
|
60
|
+
f"{title:<35} {size:<12}"
|
|
61
|
+
)
|
|
62
|
+
print_pagination(result.get("pagination", {}))
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Video commands for cli-web-pexels."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from ..core.client import PexelsClient
|
|
10
|
+
from ..core.exceptions import NotFoundError
|
|
11
|
+
from ..utils.helpers import handle_errors, sanitize_filename
|
|
12
|
+
from ..utils.output import print_json, print_pagination, print_video_detail, print_videos_table
|
|
13
|
+
|
|
14
|
+
QUALITY_ORDER = {"sd": 0, "hd": 1, "uhd": 2}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.group("videos")
|
|
18
|
+
def videos():
|
|
19
|
+
"""Search, view, and download videos."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@videos.command("search")
|
|
23
|
+
@click.argument("query")
|
|
24
|
+
@click.option(
|
|
25
|
+
"--orientation",
|
|
26
|
+
type=click.Choice(["landscape", "portrait", "square"]),
|
|
27
|
+
help="Filter by orientation.",
|
|
28
|
+
)
|
|
29
|
+
@click.option("--page", type=int, default=1, help="Page number.")
|
|
30
|
+
@click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
|
|
31
|
+
@click.pass_context
|
|
32
|
+
def search(ctx, query, orientation, page, json_mode):
|
|
33
|
+
"""Search videos by keyword."""
|
|
34
|
+
json_mode = json_mode or ctx.obj.get("json", False)
|
|
35
|
+
with handle_errors(json_mode):
|
|
36
|
+
client = PexelsClient()
|
|
37
|
+
result = client.search_videos(query, page=page, orientation=orientation)
|
|
38
|
+
videos_list = result.get("data", [])
|
|
39
|
+
pagination = result.get("pagination", {})
|
|
40
|
+
|
|
41
|
+
if json_mode:
|
|
42
|
+
print_json(
|
|
43
|
+
{
|
|
44
|
+
"query": query,
|
|
45
|
+
"page": page,
|
|
46
|
+
"total_results": pagination.get("total_results", 0),
|
|
47
|
+
"total_pages": pagination.get("total_pages", 0),
|
|
48
|
+
"results": videos_list,
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
total = pagination.get("total_results", 0)
|
|
53
|
+
click.echo(f"\n Found {total:,} videos for '{query}'")
|
|
54
|
+
print_videos_table(videos_list)
|
|
55
|
+
print_pagination(pagination)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@videos.command("get")
|
|
59
|
+
@click.argument("slug")
|
|
60
|
+
@click.option("--json", "json_mode", is_flag=True, help="Output as JSON.")
|
|
61
|
+
@click.pass_context
|
|
62
|
+
def get(ctx, slug, json_mode):
|
|
63
|
+
"""Get video details by slug or ID (e.g., 'long-narrow-road-856479' or '856479')."""
|
|
64
|
+
json_mode = json_mode or ctx.obj.get("json", False)
|
|
65
|
+
with handle_errors(json_mode):
|
|
66
|
+
client = PexelsClient()
|
|
67
|
+
video = client.get_video(slug)
|
|
68
|
+
|
|
69
|
+
if json_mode:
|
|
70
|
+
print_json(video)
|
|
71
|
+
else:
|
|
72
|
+
print_video_detail(video)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@videos.command("download")
|
|
76
|
+
@click.argument("slug")
|
|
77
|
+
@click.option(
|
|
78
|
+
"--quality",
|
|
79
|
+
type=click.Choice(["sd", "hd", "uhd"]),
|
|
80
|
+
default="hd",
|
|
81
|
+
help="Video quality (default: hd).",
|
|
82
|
+
)
|
|
83
|
+
@click.option("--output", "-o", type=click.Path(), help="Output file path.")
|
|
84
|
+
@click.pass_context
|
|
85
|
+
def download(ctx, slug, quality, output):
|
|
86
|
+
"""Download a video by slug or ID."""
|
|
87
|
+
json_mode = ctx.obj.get("json", False)
|
|
88
|
+
with handle_errors(json_mode):
|
|
89
|
+
client = PexelsClient()
|
|
90
|
+
video = client.get_video(slug)
|
|
91
|
+
|
|
92
|
+
video_files = video.get("video_files", [])
|
|
93
|
+
if not video_files:
|
|
94
|
+
raise NotFoundError(f"No video files available for '{slug}'")
|
|
95
|
+
|
|
96
|
+
# Find best matching file by quality
|
|
97
|
+
best = _select_video_file(video_files, quality)
|
|
98
|
+
if not best:
|
|
99
|
+
raise NotFoundError(f"No downloadable video file found for '{slug}'")
|
|
100
|
+
|
|
101
|
+
download_url = best.get("link")
|
|
102
|
+
if not download_url:
|
|
103
|
+
raise NotFoundError(f"No download link for selected quality ({best.get('quality')})")
|
|
104
|
+
|
|
105
|
+
# Determine output filename
|
|
106
|
+
if not output:
|
|
107
|
+
title = video.get("title") or slug
|
|
108
|
+
safe_name = sanitize_filename(title)
|
|
109
|
+
output = f"{safe_name}.mp4"
|
|
110
|
+
|
|
111
|
+
out_path = Path(output)
|
|
112
|
+
client.download_file(download_url, str(out_path))
|
|
113
|
+
|
|
114
|
+
file_size = out_path.stat().st_size
|
|
115
|
+
if json_mode:
|
|
116
|
+
print_json(
|
|
117
|
+
{
|
|
118
|
+
"video_id": video.get("id"),
|
|
119
|
+
"slug": slug,
|
|
120
|
+
"quality": best.get("quality"),
|
|
121
|
+
"resolution": f"{best.get('width')}x{best.get('height')}",
|
|
122
|
+
"file": str(out_path),
|
|
123
|
+
"bytes": file_size,
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
click.echo(f" Downloaded: {out_path} ({file_size:,} bytes)")
|
|
128
|
+
click.echo(
|
|
129
|
+
f" Quality: {best.get('quality')} "
|
|
130
|
+
f"({best.get('width')}x{best.get('height')}, "
|
|
131
|
+
f"{best.get('fps', '?')}fps)"
|
|
132
|
+
)
|
|
133
|
+
click.echo(f" Video: {video.get('title') or slug}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _select_video_file(video_files: list[dict], target_quality: str) -> dict | None:
|
|
137
|
+
"""Select the best video file matching the requested quality.
|
|
138
|
+
|
|
139
|
+
Picks the highest-resolution file at the target quality level.
|
|
140
|
+
Falls back to the closest available quality if no exact match.
|
|
141
|
+
"""
|
|
142
|
+
target_rank = QUALITY_ORDER.get(target_quality, 1)
|
|
143
|
+
|
|
144
|
+
# Filter exact matches and pick highest resolution
|
|
145
|
+
exact = [f for f in video_files if f.get("quality") == target_quality]
|
|
146
|
+
if exact:
|
|
147
|
+
return max(exact, key=lambda f: f.get("width", 0) * f.get("height", 0))
|
|
148
|
+
|
|
149
|
+
# No exact match — find closest quality
|
|
150
|
+
# Sort candidates by distance to target rank, then by resolution descending
|
|
151
|
+
candidates = [f for f in video_files if f.get("link")]
|
|
152
|
+
if not candidates:
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
def sort_key(f):
|
|
156
|
+
f_rank = QUALITY_ORDER.get(f.get("quality", ""), 1)
|
|
157
|
+
distance = abs(f_rank - target_rank)
|
|
158
|
+
resolution = f.get("width", 0) * f.get("height", 0)
|
|
159
|
+
return (distance, -resolution)
|
|
160
|
+
|
|
161
|
+
candidates.sort(key=sort_key)
|
|
162
|
+
return candidates[0]
|
|
File without changes
|