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.
@@ -0,0 +1,3 @@
1
+ TMDB_API_KEY=your_api_key_here
2
+ MOVIES_DIR=./downloads/movies
3
+ TV_DIR=./downloads/tv
@@ -0,0 +1,2 @@
1
+ include README.md
2
+ include .env.example
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ moviefinder = moviefinder.cli:main
@@ -0,0 +1,3 @@
1
+ python-dotenv>=1.0.0
2
+ requests>=2.31.0
3
+ yt-dlp>=2024.0.0
@@ -0,0 +1 @@
1
+ moviefinder