vidsrc-dlp 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.
- vidsrc_dlp-0.1.0/.env.example +3 -0
- vidsrc_dlp-0.1.0/MANIFEST.in +2 -0
- vidsrc_dlp-0.1.0/PKG-INFO +110 -0
- vidsrc_dlp-0.1.0/README.md +86 -0
- vidsrc_dlp-0.1.0/moviefinder/__init__.py +1 -0
- vidsrc_dlp-0.1.0/moviefinder/cli.py +141 -0
- vidsrc_dlp-0.1.0/moviefinder/config.py +53 -0
- vidsrc_dlp-0.1.0/moviefinder/downloader.py +114 -0
- vidsrc_dlp-0.1.0/moviefinder/resolver.py +169 -0
- vidsrc_dlp-0.1.0/moviefinder/tmdb.py +143 -0
- vidsrc_dlp-0.1.0/moviefinder/utils.py +55 -0
- vidsrc_dlp-0.1.0/pyproject.toml +41 -0
- vidsrc_dlp-0.1.0/setup.cfg +4 -0
- vidsrc_dlp-0.1.0/vidsrc_dlp.egg-info/PKG-INFO +110 -0
- vidsrc_dlp-0.1.0/vidsrc_dlp.egg-info/SOURCES.txt +17 -0
- vidsrc_dlp-0.1.0/vidsrc_dlp.egg-info/dependency_links.txt +1 -0
- vidsrc_dlp-0.1.0/vidsrc_dlp.egg-info/entry_points.txt +2 -0
- vidsrc_dlp-0.1.0/vidsrc_dlp.egg-info/requires.txt +3 -0
- vidsrc_dlp-0.1.0/vidsrc_dlp.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vidsrc-dlp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI to search TMDB, resolve VidSrc streams, and download via yt-dlp
|
|
5
|
+
Author: Jeevan
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jeevan/vidsrc-dlp
|
|
8
|
+
Project-URL: Source, https://github.com/jeevan/vidsrc-dlp
|
|
9
|
+
Project-URL: BugTracker, https://github.com/jeevan/vidsrc-dlp/issues
|
|
10
|
+
Keywords: vidsrc,tmdb,yt-dlp,video,downloader
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Multimedia :: Video
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
22
|
+
Requires-Dist: requests>=2.31.0
|
|
23
|
+
Requires-Dist: yt-dlp>=2024.0.0
|
|
24
|
+
|
|
25
|
+
# vidsrc-dlp
|
|
26
|
+
|
|
27
|
+
Search TMDB, resolve a playable HLS stream via VidSrc's 4-hop chain, and download via yt-dlp.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install vidsrc-dlp
|
|
33
|
+
cp .env.example .env # add your TMDB_API_KEY
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or for local development:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
git clone https://github.com/jeevan/vidsrc-dlp
|
|
40
|
+
cd vidsrc-dlp
|
|
41
|
+
python -m venv .venv && source .venv/bin/activate
|
|
42
|
+
pip install -e .
|
|
43
|
+
cp .env.example .env # add your TMDB_API_KEY
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Movies
|
|
50
|
+
moviefinder "Inception"
|
|
51
|
+
moviefinder "The Matrix" --year 1999
|
|
52
|
+
moviefinder "Interstellar" --quality 1080p --no-confirm --verbose
|
|
53
|
+
|
|
54
|
+
# TV Shows
|
|
55
|
+
moviefinder "Breaking Bad" --type tv --season 1 --episode 1
|
|
56
|
+
moviefinder "Severance" --type tv --season 2 --episode 5
|
|
57
|
+
|
|
58
|
+
# Test with main.py (no install needed)
|
|
59
|
+
python main.py "Inception"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Output paths
|
|
63
|
+
|
|
64
|
+
Configure in `.env` or override via CLI:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
MOVIES_DIR=./downloads/movies
|
|
68
|
+
TV_DIR=./downloads/tv
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
moviefinder "Inception" --movies-dir "/Volumes/Media/Movies"
|
|
73
|
+
moviefinder "Breaking Bad" --type tv --season 1 --episode 1 --tv-dir "/Volumes/Media/TV"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Naming convention
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
Movies: {MOVIES_DIR}/Inception (2010)/Inception (2010).mp4
|
|
80
|
+
TV: {TV_DIR}/Breaking Bad/Season 01/Breaking Bad - S01E01 - Pilot.mp4
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Architecture
|
|
84
|
+
|
|
85
|
+
1. **TMDB** — search API for movie/TV metadata
|
|
86
|
+
2. **VidSrc Resolver** — `requests`-only 4-hop chain to extract an HLS URL
|
|
87
|
+
3. **Downloader** — `yt-dlp` + ffmpeg to download and transcode to MP4
|
|
88
|
+
|
|
89
|
+
## How the resolver works
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
vidsrc.to/embed/{movie|tv}/{tmdb_id}[/{season}/{episode}]
|
|
93
|
+
→ vsembed.ru iframe (parse src attr)
|
|
94
|
+
→ cloudorchestranova.com/rcp/{hash} (parse prorcp hash)
|
|
95
|
+
→ cloudorchestranova.com/prorcp/{hash} (m3u8 URLs with __TOKEN__ placeholders)
|
|
96
|
+
→ generate.php endpoints (JWT tokens)
|
|
97
|
+
→ final m3u8 URL
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Adding a new stream provider
|
|
101
|
+
|
|
102
|
+
Implement `StreamProvider` from `moviefinder.utils`.
|
|
103
|
+
|
|
104
|
+
## Acknowledgements
|
|
105
|
+
|
|
106
|
+
- **[MaheshSharan/vidsrc](https://github.com/MaheshSharan/vidsrc)** — The request-based 4-hop resolution chain was reverse-engineered from this open-source PHP scraper. Critical reference for the VidSrc token flow.
|
|
107
|
+
- **[TMDB](https://www.themoviedb.org/)** — Movie and TV metadata API. All search, detail, episode info sourced from their free tier.
|
|
108
|
+
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** — The download engine. Handles HLS fragment fetching, decryption, and ffmpeg transcoding.
|
|
109
|
+
- **[requests](https://github.com/psf/requests)** — HTTP library for the resolver chain.
|
|
110
|
+
- **[python-dotenv](https://github.com/theskumar/python-dotenv)** — Environment variable loading from `.env`.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# vidsrc-dlp
|
|
2
|
+
|
|
3
|
+
Search TMDB, resolve a playable HLS stream via VidSrc's 4-hop chain, and download via yt-dlp.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install vidsrc-dlp
|
|
9
|
+
cp .env.example .env # add your TMDB_API_KEY
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Or for local development:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
git clone https://github.com/jeevan/vidsrc-dlp
|
|
16
|
+
cd vidsrc-dlp
|
|
17
|
+
python -m venv .venv && source .venv/bin/activate
|
|
18
|
+
pip install -e .
|
|
19
|
+
cp .env.example .env # add your TMDB_API_KEY
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# Movies
|
|
26
|
+
moviefinder "Inception"
|
|
27
|
+
moviefinder "The Matrix" --year 1999
|
|
28
|
+
moviefinder "Interstellar" --quality 1080p --no-confirm --verbose
|
|
29
|
+
|
|
30
|
+
# TV Shows
|
|
31
|
+
moviefinder "Breaking Bad" --type tv --season 1 --episode 1
|
|
32
|
+
moviefinder "Severance" --type tv --season 2 --episode 5
|
|
33
|
+
|
|
34
|
+
# Test with main.py (no install needed)
|
|
35
|
+
python main.py "Inception"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Output paths
|
|
39
|
+
|
|
40
|
+
Configure in `.env` or override via CLI:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
MOVIES_DIR=./downloads/movies
|
|
44
|
+
TV_DIR=./downloads/tv
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
moviefinder "Inception" --movies-dir "/Volumes/Media/Movies"
|
|
49
|
+
moviefinder "Breaking Bad" --type tv --season 1 --episode 1 --tv-dir "/Volumes/Media/TV"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Naming convention
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
Movies: {MOVIES_DIR}/Inception (2010)/Inception (2010).mp4
|
|
56
|
+
TV: {TV_DIR}/Breaking Bad/Season 01/Breaking Bad - S01E01 - Pilot.mp4
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Architecture
|
|
60
|
+
|
|
61
|
+
1. **TMDB** — search API for movie/TV metadata
|
|
62
|
+
2. **VidSrc Resolver** — `requests`-only 4-hop chain to extract an HLS URL
|
|
63
|
+
3. **Downloader** — `yt-dlp` + ffmpeg to download and transcode to MP4
|
|
64
|
+
|
|
65
|
+
## How the resolver works
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
vidsrc.to/embed/{movie|tv}/{tmdb_id}[/{season}/{episode}]
|
|
69
|
+
→ vsembed.ru iframe (parse src attr)
|
|
70
|
+
→ cloudorchestranova.com/rcp/{hash} (parse prorcp hash)
|
|
71
|
+
→ cloudorchestranova.com/prorcp/{hash} (m3u8 URLs with __TOKEN__ placeholders)
|
|
72
|
+
→ generate.php endpoints (JWT tokens)
|
|
73
|
+
→ final m3u8 URL
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Adding a new stream provider
|
|
77
|
+
|
|
78
|
+
Implement `StreamProvider` from `moviefinder.utils`.
|
|
79
|
+
|
|
80
|
+
## Acknowledgements
|
|
81
|
+
|
|
82
|
+
- **[MaheshSharan/vidsrc](https://github.com/MaheshSharan/vidsrc)** — The request-based 4-hop resolution chain was reverse-engineered from this open-source PHP scraper. Critical reference for the VidSrc token flow.
|
|
83
|
+
- **[TMDB](https://www.themoviedb.org/)** — Movie and TV metadata API. All search, detail, episode info sourced from their free tier.
|
|
84
|
+
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** — The download engine. Handles HLS fragment fetching, decryption, and ffmpeg transcoding.
|
|
85
|
+
- **[requests](https://github.com/psf/requests)** — HTTP library for the resolver chain.
|
|
86
|
+
- **[python-dotenv](https://github.com/theskumar/python-dotenv)** — Environment variable loading from `.env`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from moviefinder.config import load_config
|
|
7
|
+
from moviefinder.downloader import VideoDownloader
|
|
8
|
+
from moviefinder.resolver import VidSrcResolver
|
|
9
|
+
from moviefinder.tmdb import TMDBClient
|
|
10
|
+
from moviefinder.utils import MediaType, setup_logging
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("moviefinder.cli")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="moviefinder",
|
|
18
|
+
description="Search TMDB, resolve stream, and download movies & TV shows.",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument("query", nargs="?", help="Movie or TV show title to search for")
|
|
21
|
+
parser.add_argument("--type", choices=["movie", "tv"], default="movie", help="Media type")
|
|
22
|
+
parser.add_argument("--season", type=int, help="Season number (for TV)")
|
|
23
|
+
parser.add_argument("--episode", type=int, help="Episode number (for TV)")
|
|
24
|
+
parser.add_argument("--year", type=int, help="Filter by release year")
|
|
25
|
+
parser.add_argument("--quality", default="best", help="Quality (e.g. 1080p, 720p, best)")
|
|
26
|
+
parser.add_argument("--movies-dir", help="Override movies output directory")
|
|
27
|
+
parser.add_argument("--tv-dir", help="Override TV output directory")
|
|
28
|
+
parser.add_argument("--no-confirm", action="store_true", help="Skip confirmation prompt")
|
|
29
|
+
parser.add_argument("--verbose", action="store_true", help="Enable debug logging")
|
|
30
|
+
parser.add_argument("--verbose-ytdlp", action="store_true", help="Show yt-dlp output")
|
|
31
|
+
return parser.parse_args(argv)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main(argv: list[str] | None = None) -> None:
|
|
35
|
+
args = parse_args(argv)
|
|
36
|
+
|
|
37
|
+
if args.query is None:
|
|
38
|
+
print("usage: moviefinder <title> [options]")
|
|
39
|
+
print()
|
|
40
|
+
print("Movies:")
|
|
41
|
+
print(' moviefinder "Inception"')
|
|
42
|
+
print(' moviefinder "The Matrix" --year 1999')
|
|
43
|
+
print()
|
|
44
|
+
print("TV:")
|
|
45
|
+
print(' moviefinder "Breaking Bad" --type tv --season 1 --episode 1')
|
|
46
|
+
raise SystemExit(1)
|
|
47
|
+
|
|
48
|
+
setup_logging(args.verbose)
|
|
49
|
+
|
|
50
|
+
if args.type == "tv":
|
|
51
|
+
if args.season is None or args.episode is None:
|
|
52
|
+
logger.error("--season and --episode are required for TV shows")
|
|
53
|
+
raise SystemExit(1)
|
|
54
|
+
|
|
55
|
+
config = load_config(
|
|
56
|
+
movies_dir=args.movies_dir,
|
|
57
|
+
tv_dir=args.tv_dir,
|
|
58
|
+
quality=args.quality,
|
|
59
|
+
no_confirm=args.no_confirm,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
tmdb = TMDBClient(api_key=config.tmdb_api_key)
|
|
63
|
+
media_type = MediaType.TV if args.type == "tv" else MediaType.MOVIE
|
|
64
|
+
search_fn = tmdb.search_tv if args.type == "tv" else tmdb.search_movie
|
|
65
|
+
detail_fn = tmdb.get_tv_details if args.type == "tv" else tmdb.get_movie_details
|
|
66
|
+
|
|
67
|
+
results = search_fn(args.query, year=args.year)
|
|
68
|
+
if not results:
|
|
69
|
+
logger.error("No %s found for: %s", args.type, args.query)
|
|
70
|
+
raise SystemExit(1)
|
|
71
|
+
|
|
72
|
+
best = results[0]
|
|
73
|
+
logger.info("Found: %s", _format_media_info(best))
|
|
74
|
+
|
|
75
|
+
# Enrich with full details
|
|
76
|
+
detailed = detail_fn(best.id)
|
|
77
|
+
if detailed:
|
|
78
|
+
best = detailed
|
|
79
|
+
|
|
80
|
+
# Fetch episode title for TV
|
|
81
|
+
if args.type == "tv":
|
|
82
|
+
ep_title = tmdb.get_episode_title(best.id, args.season, args.episode)
|
|
83
|
+
best.season = args.season
|
|
84
|
+
best.episode = args.episode
|
|
85
|
+
best.episode_title = ep_title if ep_title else None
|
|
86
|
+
best.media_type = MediaType.TV
|
|
87
|
+
|
|
88
|
+
logger.info(
|
|
89
|
+
"Metadata: %s",
|
|
90
|
+
_format_metadata(best),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if not args.no_confirm:
|
|
94
|
+
label = (
|
|
95
|
+
f"{best.title} S{best.season:02d}E{best.episode:02d}"
|
|
96
|
+
if best.media_type == MediaType.TV
|
|
97
|
+
else best.title
|
|
98
|
+
)
|
|
99
|
+
answer = input(f"Download '{label}'? [Y/n] ").strip().lower()
|
|
100
|
+
if answer and answer not in ("y", "yes", ""):
|
|
101
|
+
logger.info("Skipped.")
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
resolver = VidSrcResolver()
|
|
105
|
+
stream = resolver.resolve(
|
|
106
|
+
best.id,
|
|
107
|
+
media_type=args.type,
|
|
108
|
+
season=best.season,
|
|
109
|
+
episode=best.episode,
|
|
110
|
+
)
|
|
111
|
+
if stream is None:
|
|
112
|
+
logger.error("No stream available for %s", best.title)
|
|
113
|
+
raise SystemExit(1)
|
|
114
|
+
|
|
115
|
+
logger.info("Stream URL: %s", stream.url[:100])
|
|
116
|
+
|
|
117
|
+
downloader = VideoDownloader(config)
|
|
118
|
+
success = downloader.download(stream, best)
|
|
119
|
+
raise SystemExit(0 if success else 1)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _format_media_info(media) -> str:
|
|
123
|
+
parts = [media.title]
|
|
124
|
+
if media.year:
|
|
125
|
+
parts.append(f"({media.year})")
|
|
126
|
+
return " ".join(parts)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _format_metadata(media) -> str:
|
|
130
|
+
parts = []
|
|
131
|
+
if media.imdb_id:
|
|
132
|
+
parts.append(f"IMDb: {media.imdb_id}")
|
|
133
|
+
if media.vote_average:
|
|
134
|
+
parts.append(f"Rating: {media.vote_average:.1f}/10")
|
|
135
|
+
if media.year:
|
|
136
|
+
parts.append(f"Year: {media.year}")
|
|
137
|
+
if media.genres:
|
|
138
|
+
parts.append(f"Genres: {', '.join(media.genres[:3])}")
|
|
139
|
+
if media.media_type == MediaType.TV and media.episode_title:
|
|
140
|
+
parts.append(f"Episode: {media.episode_title}")
|
|
141
|
+
return " | ".join(parts)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Config:
|
|
12
|
+
tmdb_api_key: str
|
|
13
|
+
movies_dir: Path
|
|
14
|
+
tv_dir: Path
|
|
15
|
+
quality: str = "best"
|
|
16
|
+
no_confirm: bool = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_config(
|
|
20
|
+
movies_dir: str | None = None,
|
|
21
|
+
tv_dir: str | None = None,
|
|
22
|
+
quality: str | None = None,
|
|
23
|
+
no_confirm: bool = False,
|
|
24
|
+
) -> Config:
|
|
25
|
+
load_dotenv()
|
|
26
|
+
|
|
27
|
+
api_key = os.getenv("TMDB_API_KEY")
|
|
28
|
+
if not api_key:
|
|
29
|
+
raise ValueError(
|
|
30
|
+
"TMDB_API_KEY not found in .env file. "
|
|
31
|
+
"Create a .env file with TMDB_API_KEY=your_key_here"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
default_downloads = Path.cwd() / "downloads"
|
|
35
|
+
|
|
36
|
+
resolved_movies = (
|
|
37
|
+
Path(movies_dir)
|
|
38
|
+
if movies_dir
|
|
39
|
+
else Path(os.getenv("MOVIES_DIR", str(default_downloads / "movies")))
|
|
40
|
+
)
|
|
41
|
+
resolved_tv = (
|
|
42
|
+
Path(tv_dir)
|
|
43
|
+
if tv_dir
|
|
44
|
+
else Path(os.getenv("TV_DIR", str(default_downloads / "tv")))
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
return Config(
|
|
48
|
+
tmdb_api_key=api_key,
|
|
49
|
+
movies_dir=resolved_movies,
|
|
50
|
+
tv_dir=resolved_tv,
|
|
51
|
+
quality=quality or "best",
|
|
52
|
+
no_confirm=no_confirm,
|
|
53
|
+
)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yt_dlp
|
|
8
|
+
|
|
9
|
+
from moviefinder.config import Config
|
|
10
|
+
from moviefinder.utils import Media, MediaType, StreamInfo
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("moviefinder.downloader")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class VideoDownloader:
|
|
17
|
+
config: Config
|
|
18
|
+
|
|
19
|
+
def download(self, stream: StreamInfo, media: Media) -> bool:
|
|
20
|
+
if media.media_type == MediaType.TV:
|
|
21
|
+
output_dir, filename = self._tv_path(media)
|
|
22
|
+
else:
|
|
23
|
+
output_dir, filename = self._movie_path(media)
|
|
24
|
+
|
|
25
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
output_template = str(output_dir / f"{filename}.%(ext)s")
|
|
27
|
+
|
|
28
|
+
ydl_opts: dict = {
|
|
29
|
+
"format": self._build_format_spec(),
|
|
30
|
+
"outtmpl": output_template,
|
|
31
|
+
"merge_output_format": "mp4",
|
|
32
|
+
"http_headers": {
|
|
33
|
+
"User-Agent": stream.headers.get(
|
|
34
|
+
"User-Agent",
|
|
35
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
36
|
+
),
|
|
37
|
+
"Referer": stream.referer or stream.url,
|
|
38
|
+
},
|
|
39
|
+
"postprocessors": [
|
|
40
|
+
{
|
|
41
|
+
"key": "FFmpegVideoConvertor",
|
|
42
|
+
"preferedformat": "mp4",
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"progress_hooks": [self._progress_hook],
|
|
46
|
+
"quiet": True,
|
|
47
|
+
"no_warnings": True,
|
|
48
|
+
"extractor_retries": 3,
|
|
49
|
+
"fragment_retries": 5,
|
|
50
|
+
"retries": 5,
|
|
51
|
+
"ignoreerrors": False,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
logger.info("Downloading %s", filename)
|
|
56
|
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
57
|
+
ydl.download([stream.url])
|
|
58
|
+
logger.info("Download complete: %s", filename)
|
|
59
|
+
return True
|
|
60
|
+
except yt_dlp.utils.DownloadError as e:
|
|
61
|
+
logger.error("Download failed: %s", e)
|
|
62
|
+
return False
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.error("Unexpected error: %s", e)
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
def _movie_path(self, media: Media) -> tuple[Path, str]:
|
|
68
|
+
label = self._media_label(media)
|
|
69
|
+
folder = self.config.movies_dir / label
|
|
70
|
+
return folder, label
|
|
71
|
+
|
|
72
|
+
def _tv_path(self, media: Media) -> tuple[Path, str]:
|
|
73
|
+
show_dir = self.config.tv_dir / self._safe_filename(media.title)
|
|
74
|
+
season_dir = show_dir / f"Season {media.season or 1}"
|
|
75
|
+
ep = media.episode or 1
|
|
76
|
+
parts = [
|
|
77
|
+
self._safe_filename(media.title),
|
|
78
|
+
f"S{media.season or 1:02d}E{ep:02d}",
|
|
79
|
+
]
|
|
80
|
+
if media.episode_title:
|
|
81
|
+
parts.append(self._safe_filename(media.episode_title))
|
|
82
|
+
filename = " - ".join(parts)
|
|
83
|
+
return season_dir, filename
|
|
84
|
+
|
|
85
|
+
def _media_label(self, media: Media) -> str:
|
|
86
|
+
title = self._safe_filename(media.title)
|
|
87
|
+
if media.year:
|
|
88
|
+
return f"{title} ({media.year})"
|
|
89
|
+
return title
|
|
90
|
+
|
|
91
|
+
def _build_format_spec(self) -> str:
|
|
92
|
+
quality = self.config.quality
|
|
93
|
+
if quality == "best":
|
|
94
|
+
return "bestvideo+bestaudio/best"
|
|
95
|
+
try:
|
|
96
|
+
height = quality.replace("p", "").strip()
|
|
97
|
+
int(height)
|
|
98
|
+
return f"bestvideo[height<={height}]+bestaudio/best[height<={height}]/best"
|
|
99
|
+
except ValueError:
|
|
100
|
+
return "bestvideo+bestaudio/best"
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _progress_hook(d: dict) -> None:
|
|
104
|
+
if d["status"] == "downloading":
|
|
105
|
+
total = d.get("total_bytes") or d.get("total_bytes_estimate", 0)
|
|
106
|
+
downloaded = d.get("downloaded_bytes", 0)
|
|
107
|
+
if total > 0:
|
|
108
|
+
logger.info("Progress: %.1f%%", downloaded / total * 100)
|
|
109
|
+
elif d["status"] == "finished":
|
|
110
|
+
logger.info("Post-processing...")
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _safe_filename(title: str) -> str:
|
|
114
|
+
return "".join(c if c.isalnum() or c in " ._-" else "_" for c in title).strip()
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from moviefinder.utils import StreamInfo, StreamProvider
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("moviefinder.resolver")
|
|
13
|
+
|
|
14
|
+
HEADERS = {
|
|
15
|
+
"User-Agent": (
|
|
16
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
17
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
18
|
+
"Chrome/124.0.0.0 Safari/537.36"
|
|
19
|
+
),
|
|
20
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
21
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class VidSrcResolver(StreamProvider):
|
|
27
|
+
base_domain: str = "vidsrc.to"
|
|
28
|
+
request_delay: float = 0.3
|
|
29
|
+
timeout: int = 30
|
|
30
|
+
|
|
31
|
+
def resolve(
|
|
32
|
+
self,
|
|
33
|
+
tmdb_id: int,
|
|
34
|
+
media_type: str = "movie",
|
|
35
|
+
season: int | None = None,
|
|
36
|
+
episode: int | None = None,
|
|
37
|
+
) -> StreamInfo | None:
|
|
38
|
+
logger.info(
|
|
39
|
+
"Resolving TMDB ID %d (%s) via %s", tmdb_id, media_type, self.base_domain
|
|
40
|
+
)
|
|
41
|
+
try:
|
|
42
|
+
return self._resolve(tmdb_id, media_type, season, episode)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
logger.error("Resolution failed: %s", e)
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
def _resolve(
|
|
48
|
+
self,
|
|
49
|
+
tmdb_id: int,
|
|
50
|
+
media_type: str,
|
|
51
|
+
season: int | None,
|
|
52
|
+
episode: int | None,
|
|
53
|
+
) -> StreamInfo | None:
|
|
54
|
+
session = requests.Session()
|
|
55
|
+
session.headers.update(HEADERS)
|
|
56
|
+
|
|
57
|
+
if media_type == "tv" and season is not None and episode is not None:
|
|
58
|
+
embed_url = (
|
|
59
|
+
f"https://{self.base_domain}/embed/tv/{tmdb_id}/{season}/{episode}"
|
|
60
|
+
)
|
|
61
|
+
else:
|
|
62
|
+
embed_url = f"https://{self.base_domain}/embed/movie/{tmdb_id}"
|
|
63
|
+
|
|
64
|
+
r1 = session.get(embed_url, timeout=15)
|
|
65
|
+
vsembed_url = self._extract_vsembed(r1.text, embed_url)
|
|
66
|
+
if not vsembed_url:
|
|
67
|
+
logger.error("No vsembed iframe found on %s", embed_url)
|
|
68
|
+
return None
|
|
69
|
+
logger.info("Step 1: %s", vsembed_url)
|
|
70
|
+
time.sleep(self.request_delay)
|
|
71
|
+
|
|
72
|
+
r2 = session.get(vsembed_url, headers={"Referer": embed_url}, timeout=15)
|
|
73
|
+
hashes = re.findall(r'data-hash=["\']([A-Za-z0-9+/=_-]+)["\']', r2.text)
|
|
74
|
+
if not hashes:
|
|
75
|
+
logger.error("No data-hash found on vsembed")
|
|
76
|
+
return None
|
|
77
|
+
logger.info("Step 2: %d source hash(es) found", len(hashes))
|
|
78
|
+
time.sleep(self.request_delay)
|
|
79
|
+
|
|
80
|
+
for i, h in enumerate(hashes):
|
|
81
|
+
logger.debug("Trying source %d/%d", i + 1, len(hashes))
|
|
82
|
+
result = self._resolve_source(session, h, vsembed_url)
|
|
83
|
+
if result:
|
|
84
|
+
logger.info("Stream resolved from source %d", i + 1)
|
|
85
|
+
return result
|
|
86
|
+
time.sleep(self.request_delay)
|
|
87
|
+
|
|
88
|
+
logger.error("All %d sources failed", len(hashes))
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
def _resolve_source(
|
|
92
|
+
self, session: requests.Session, rcp_hash: str, referer: str
|
|
93
|
+
) -> StreamInfo | None:
|
|
94
|
+
rcp_url = f"https://cloudorchestranova.com/rcp/{rcp_hash}"
|
|
95
|
+
r3 = session.get(rcp_url, headers={"Referer": referer}, timeout=self.timeout)
|
|
96
|
+
if r3.status_code != 200:
|
|
97
|
+
logger.warning("RCP request failed: %d", r3.status_code)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
prorcp = re.search(r"prorcp/([A-Za-z0-9+/=_-]+)", r3.text)
|
|
101
|
+
if not prorcp:
|
|
102
|
+
logger.warning("No prorcp hash in RCP response")
|
|
103
|
+
return None
|
|
104
|
+
logger.debug("Step 3: prorcp hash found")
|
|
105
|
+
time.sleep(self.request_delay)
|
|
106
|
+
|
|
107
|
+
prorcp_url = f"https://cloudorchestranova.com/prorcp/{prorcp.group(1)}"
|
|
108
|
+
r4 = session.get(
|
|
109
|
+
prorcp_url, headers={"Referer": rcp_url}, timeout=self.timeout
|
|
110
|
+
)
|
|
111
|
+
if r4.status_code != 200:
|
|
112
|
+
logger.warning("prorcp request failed: %d", r4.status_code)
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
m3u8_urls = re.findall(r"https?://[^\"' ]+\.m3u8[^\"' ]*", r4.text)
|
|
116
|
+
if not m3u8_urls:
|
|
117
|
+
logger.warning("No m3u8 URLs in prorcp response")
|
|
118
|
+
return None
|
|
119
|
+
logger.debug("Step 4: %d raw m3u8 URL(s) found", len(m3u8_urls))
|
|
120
|
+
time.sleep(self.request_delay)
|
|
121
|
+
|
|
122
|
+
token_main = self._fetch_token(
|
|
123
|
+
session, "https://peregrinepalaver.space/generate.php", rcp_url
|
|
124
|
+
)
|
|
125
|
+
token_pg = self._fetch_token(
|
|
126
|
+
session, "https://app2.putgate.com/generate.php", rcp_url
|
|
127
|
+
)
|
|
128
|
+
logger.debug("Step 5: tokens resolved")
|
|
129
|
+
|
|
130
|
+
for raw_url in m3u8_urls:
|
|
131
|
+
resolved = raw_url
|
|
132
|
+
if token_main:
|
|
133
|
+
resolved = resolved.replace("__TOKEN__", token_main)
|
|
134
|
+
if token_pg:
|
|
135
|
+
resolved = resolved.replace("__TOKENPG__", token_pg)
|
|
136
|
+
if "{v" not in resolved:
|
|
137
|
+
return StreamInfo(
|
|
138
|
+
url=resolved,
|
|
139
|
+
headers={"User-Agent": HEADERS["User-Agent"]},
|
|
140
|
+
referer="https://cloudorchestranova.com/",
|
|
141
|
+
stream_type="hls",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
logger.warning("All m3u8 URLs had unresolved placeholders")
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _extract_vsembed(html: str, page_url: str) -> str | None:
|
|
149
|
+
src = re.search(r'src=["\']([^"\']*vsembed[^"\']*)["\']', html)
|
|
150
|
+
if not src:
|
|
151
|
+
src = re.search(r'src=["\']([^"\']*embed[^"\']*)["\']', html)
|
|
152
|
+
if not src:
|
|
153
|
+
return None
|
|
154
|
+
url = src.group(1)
|
|
155
|
+
if url.startswith("//"):
|
|
156
|
+
url = "https:" + url
|
|
157
|
+
return url
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _fetch_token(
|
|
161
|
+
session: requests.Session, url: str, referer: str
|
|
162
|
+
) -> str | None:
|
|
163
|
+
try:
|
|
164
|
+
r = session.get(url, headers={"Referer": referer}, timeout=10)
|
|
165
|
+
if r.status_code == 200 and r.text.strip():
|
|
166
|
+
return r.text.strip()
|
|
167
|
+
except requests.RequestException:
|
|
168
|
+
pass
|
|
169
|
+
return None
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from moviefinder.utils import Media, MediaType
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("moviefinder.tmdb")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TMDBClient:
|
|
13
|
+
def __init__(self, api_key: str):
|
|
14
|
+
self.api_key = api_key
|
|
15
|
+
self.base_url = "https://api.themoviedb.org/3"
|
|
16
|
+
|
|
17
|
+
def search_movie(self, query: str, year: int | None = None) -> list[Media]:
|
|
18
|
+
params = {"api_key": self.api_key, "query": query.strip(), "language": "en-US"}
|
|
19
|
+
if year:
|
|
20
|
+
params["primary_release_year"] = year
|
|
21
|
+
return self._search("search/movie", params, MediaType.MOVIE)
|
|
22
|
+
|
|
23
|
+
def search_tv(self, query: str, year: int | None = None) -> list[Media]:
|
|
24
|
+
params = {"api_key": self.api_key, "query": query.strip(), "language": "en-US"}
|
|
25
|
+
if year:
|
|
26
|
+
params["first_air_date_year"] = year
|
|
27
|
+
return self._search("search/tv", params, MediaType.TV)
|
|
28
|
+
|
|
29
|
+
def _search(
|
|
30
|
+
self, endpoint: str, params: dict, media_type: MediaType
|
|
31
|
+
) -> list[Media]:
|
|
32
|
+
try:
|
|
33
|
+
r = requests.get(
|
|
34
|
+
f"{self.base_url}/{endpoint}", params=params, timeout=10
|
|
35
|
+
)
|
|
36
|
+
r.raise_for_status()
|
|
37
|
+
data = r.json()
|
|
38
|
+
except requests.RequestException as e:
|
|
39
|
+
logger.error("TMDB search request failed: %s", e)
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
results = data.get("results", [])
|
|
43
|
+
if not results:
|
|
44
|
+
logger.info("No TMDB results for %s", params.get("query"))
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
items = []
|
|
48
|
+
for m in results:
|
|
49
|
+
date_field = "release_date" if media_type == MediaType.MOVIE else "first_air_date"
|
|
50
|
+
raw_date = m.get(date_field, "") or ""
|
|
51
|
+
year_val = _parse_year(raw_date)
|
|
52
|
+
items.append(
|
|
53
|
+
Media(
|
|
54
|
+
id=m["id"],
|
|
55
|
+
title=m.get("title" if media_type == MediaType.MOVIE else "name", "Unknown"),
|
|
56
|
+
media_type=media_type,
|
|
57
|
+
year=year_val,
|
|
58
|
+
release_date=raw_date,
|
|
59
|
+
overview=m.get("overview", "") or "",
|
|
60
|
+
vote_average=m.get("vote_average"),
|
|
61
|
+
poster_path=m.get("poster_path"),
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
logger.info(
|
|
66
|
+
"TMDB %s search for '%s': %d results",
|
|
67
|
+
media_type.value,
|
|
68
|
+
params.get("query"),
|
|
69
|
+
len(items),
|
|
70
|
+
)
|
|
71
|
+
return items
|
|
72
|
+
|
|
73
|
+
def get_movie_details(self, tmdb_id: int) -> Media | None:
|
|
74
|
+
return self._get_details(f"movie/{tmdb_id}", MediaType.MOVIE)
|
|
75
|
+
|
|
76
|
+
def get_tv_details(self, tmdb_id: int) -> Media | None:
|
|
77
|
+
return self._get_details(f"tv/{tmdb_id}", MediaType.TV)
|
|
78
|
+
|
|
79
|
+
def _get_details(self, endpoint: str, media_type: MediaType) -> Media | None:
|
|
80
|
+
params = {"api_key": self.api_key, "language": "en-US"}
|
|
81
|
+
try:
|
|
82
|
+
r = requests.get(
|
|
83
|
+
f"{self.base_url}/{endpoint}", params=params, timeout=10
|
|
84
|
+
)
|
|
85
|
+
r.raise_for_status()
|
|
86
|
+
d = r.json()
|
|
87
|
+
except requests.RequestException as e:
|
|
88
|
+
logger.error("TMDB detail request failed: %s", e)
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
date_field = "release_date" if media_type == MediaType.MOVIE else "first_air_date"
|
|
92
|
+
raw_date = d.get(date_field, "") or ""
|
|
93
|
+
year_val = _parse_year(raw_date)
|
|
94
|
+
title_key = "title" if media_type == MediaType.MOVIE else "name"
|
|
95
|
+
|
|
96
|
+
genre_names = [g["name"] for g in d.get("genres", []) if "name" in g]
|
|
97
|
+
|
|
98
|
+
media = Media(
|
|
99
|
+
id=d["id"],
|
|
100
|
+
title=d.get(title_key, "Unknown"),
|
|
101
|
+
media_type=media_type,
|
|
102
|
+
year=year_val,
|
|
103
|
+
release_date=raw_date,
|
|
104
|
+
overview=d.get("overview", "") or "",
|
|
105
|
+
genres=genre_names,
|
|
106
|
+
vote_average=d.get("vote_average"),
|
|
107
|
+
poster_path=d.get("poster_path"),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Fetch external IDs for IMDb
|
|
111
|
+
try:
|
|
112
|
+
ext = requests.get(
|
|
113
|
+
f"{self.base_url}/{endpoint}/external_ids",
|
|
114
|
+
params={"api_key": self.api_key},
|
|
115
|
+
timeout=10,
|
|
116
|
+
).json()
|
|
117
|
+
media.imdb_id = ext.get("imdb_id")
|
|
118
|
+
except requests.RequestException:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
return media
|
|
122
|
+
|
|
123
|
+
def get_episode_title(self, tmdb_id: int, season: int, episode: int) -> str | None:
|
|
124
|
+
try:
|
|
125
|
+
r = requests.get(
|
|
126
|
+
f"{self.base_url}/tv/{tmdb_id}/season/{season}/episode/{episode}",
|
|
127
|
+
params={"api_key": self.api_key, "language": "en-US"},
|
|
128
|
+
timeout=10,
|
|
129
|
+
)
|
|
130
|
+
r.raise_for_status()
|
|
131
|
+
return r.json().get("name")
|
|
132
|
+
except requests.RequestException as e:
|
|
133
|
+
logger.debug("Could not fetch episode title: %s", e)
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _parse_year(date_str: str) -> int | None:
|
|
138
|
+
if date_str and len(date_str) >= 4:
|
|
139
|
+
try:
|
|
140
|
+
return int(date_str[:4])
|
|
141
|
+
except ValueError:
|
|
142
|
+
return None
|
|
143
|
+
return None
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MediaType(Enum):
|
|
8
|
+
MOVIE = "movie"
|
|
9
|
+
TV = "tv"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Media:
|
|
14
|
+
id: int
|
|
15
|
+
title: str
|
|
16
|
+
media_type: MediaType = MediaType.MOVIE
|
|
17
|
+
year: int | None = None
|
|
18
|
+
release_date: str = ""
|
|
19
|
+
overview: str = ""
|
|
20
|
+
season: int | None = None
|
|
21
|
+
episode: int | None = None
|
|
22
|
+
episode_title: str | None = None
|
|
23
|
+
imdb_id: str | None = None
|
|
24
|
+
genres: list[str] = field(default_factory=list)
|
|
25
|
+
vote_average: float | None = None
|
|
26
|
+
poster_path: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class StreamInfo:
|
|
31
|
+
url: str
|
|
32
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
33
|
+
referer: str = ""
|
|
34
|
+
stream_type: str = "hls"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class StreamProvider(ABC):
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def resolve(
|
|
40
|
+
self,
|
|
41
|
+
tmdb_id: int,
|
|
42
|
+
media_type: str = "movie",
|
|
43
|
+
season: int | None = None,
|
|
44
|
+
episode: int | None = None,
|
|
45
|
+
) -> StreamInfo | None:
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def setup_logging(verbose: bool = False) -> None:
|
|
50
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
51
|
+
handler = logging.StreamHandler()
|
|
52
|
+
handler.setFormatter(logging.Formatter("[%(levelname)s] %(name)s: %(message)s"))
|
|
53
|
+
root = logging.getLogger("moviefinder")
|
|
54
|
+
root.setLevel(level)
|
|
55
|
+
root.addHandler(handler)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vidsrc-dlp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CLI to search TMDB, resolve VidSrc streams, and download via yt-dlp"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Jeevan" },
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: End Users/Desktop",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Multimedia :: Video",
|
|
24
|
+
]
|
|
25
|
+
keywords = ["vidsrc", "tmdb", "yt-dlp", "video", "downloader"]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"python-dotenv>=1.0.0",
|
|
28
|
+
"requests>=2.31.0",
|
|
29
|
+
"yt-dlp>=2024.0.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/jeevan/vidsrc-dlp"
|
|
34
|
+
Source = "https://github.com/jeevan/vidsrc-dlp"
|
|
35
|
+
BugTracker = "https://github.com/jeevan/vidsrc-dlp/issues"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
moviefinder = "moviefinder.cli:main"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
include = ["moviefinder*"]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vidsrc-dlp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI to search TMDB, resolve VidSrc streams, and download via yt-dlp
|
|
5
|
+
Author: Jeevan
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jeevan/vidsrc-dlp
|
|
8
|
+
Project-URL: Source, https://github.com/jeevan/vidsrc-dlp
|
|
9
|
+
Project-URL: BugTracker, https://github.com/jeevan/vidsrc-dlp/issues
|
|
10
|
+
Keywords: vidsrc,tmdb,yt-dlp,video,downloader
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Multimedia :: Video
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
22
|
+
Requires-Dist: requests>=2.31.0
|
|
23
|
+
Requires-Dist: yt-dlp>=2024.0.0
|
|
24
|
+
|
|
25
|
+
# vidsrc-dlp
|
|
26
|
+
|
|
27
|
+
Search TMDB, resolve a playable HLS stream via VidSrc's 4-hop chain, and download via yt-dlp.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install vidsrc-dlp
|
|
33
|
+
cp .env.example .env # add your TMDB_API_KEY
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or for local development:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
git clone https://github.com/jeevan/vidsrc-dlp
|
|
40
|
+
cd vidsrc-dlp
|
|
41
|
+
python -m venv .venv && source .venv/bin/activate
|
|
42
|
+
pip install -e .
|
|
43
|
+
cp .env.example .env # add your TMDB_API_KEY
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Movies
|
|
50
|
+
moviefinder "Inception"
|
|
51
|
+
moviefinder "The Matrix" --year 1999
|
|
52
|
+
moviefinder "Interstellar" --quality 1080p --no-confirm --verbose
|
|
53
|
+
|
|
54
|
+
# TV Shows
|
|
55
|
+
moviefinder "Breaking Bad" --type tv --season 1 --episode 1
|
|
56
|
+
moviefinder "Severance" --type tv --season 2 --episode 5
|
|
57
|
+
|
|
58
|
+
# Test with main.py (no install needed)
|
|
59
|
+
python main.py "Inception"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Output paths
|
|
63
|
+
|
|
64
|
+
Configure in `.env` or override via CLI:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
MOVIES_DIR=./downloads/movies
|
|
68
|
+
TV_DIR=./downloads/tv
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
moviefinder "Inception" --movies-dir "/Volumes/Media/Movies"
|
|
73
|
+
moviefinder "Breaking Bad" --type tv --season 1 --episode 1 --tv-dir "/Volumes/Media/TV"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Naming convention
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
Movies: {MOVIES_DIR}/Inception (2010)/Inception (2010).mp4
|
|
80
|
+
TV: {TV_DIR}/Breaking Bad/Season 01/Breaking Bad - S01E01 - Pilot.mp4
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Architecture
|
|
84
|
+
|
|
85
|
+
1. **TMDB** — search API for movie/TV metadata
|
|
86
|
+
2. **VidSrc Resolver** — `requests`-only 4-hop chain to extract an HLS URL
|
|
87
|
+
3. **Downloader** — `yt-dlp` + ffmpeg to download and transcode to MP4
|
|
88
|
+
|
|
89
|
+
## How the resolver works
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
vidsrc.to/embed/{movie|tv}/{tmdb_id}[/{season}/{episode}]
|
|
93
|
+
→ vsembed.ru iframe (parse src attr)
|
|
94
|
+
→ cloudorchestranova.com/rcp/{hash} (parse prorcp hash)
|
|
95
|
+
→ cloudorchestranova.com/prorcp/{hash} (m3u8 URLs with __TOKEN__ placeholders)
|
|
96
|
+
→ generate.php endpoints (JWT tokens)
|
|
97
|
+
→ final m3u8 URL
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Adding a new stream provider
|
|
101
|
+
|
|
102
|
+
Implement `StreamProvider` from `moviefinder.utils`.
|
|
103
|
+
|
|
104
|
+
## Acknowledgements
|
|
105
|
+
|
|
106
|
+
- **[MaheshSharan/vidsrc](https://github.com/MaheshSharan/vidsrc)** — The request-based 4-hop resolution chain was reverse-engineered from this open-source PHP scraper. Critical reference for the VidSrc token flow.
|
|
107
|
+
- **[TMDB](https://www.themoviedb.org/)** — Movie and TV metadata API. All search, detail, episode info sourced from their free tier.
|
|
108
|
+
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** — The download engine. Handles HLS fragment fetching, decryption, and ffmpeg transcoding.
|
|
109
|
+
- **[requests](https://github.com/psf/requests)** — HTTP library for the resolver chain.
|
|
110
|
+
- **[python-dotenv](https://github.com/theskumar/python-dotenv)** — Environment variable loading from `.env`.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
.env.example
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
moviefinder/__init__.py
|
|
6
|
+
moviefinder/cli.py
|
|
7
|
+
moviefinder/config.py
|
|
8
|
+
moviefinder/downloader.py
|
|
9
|
+
moviefinder/resolver.py
|
|
10
|
+
moviefinder/tmdb.py
|
|
11
|
+
moviefinder/utils.py
|
|
12
|
+
vidsrc_dlp.egg-info/PKG-INFO
|
|
13
|
+
vidsrc_dlp.egg-info/SOURCES.txt
|
|
14
|
+
vidsrc_dlp.egg-info/dependency_links.txt
|
|
15
|
+
vidsrc_dlp.egg-info/entry_points.txt
|
|
16
|
+
vidsrc_dlp.egg-info/requires.txt
|
|
17
|
+
vidsrc_dlp.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
moviefinder
|