recycleactor commited on
Commit
88db761
·
verified ·
1 Parent(s): 92683a3

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +352 -0
app.py ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import httpx
3
+ from urllib.parse import urljoin, quote, unquote, urlparse
4
+ from fastapi import FastAPI, Request, Response
5
+ from fastapi.responses import StreamingResponse
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+
8
+ app = FastAPI()
9
+
10
+ app.add_middleware(
11
+ CORSMiddleware,
12
+ allow_origins=["*"],
13
+ allow_methods=["*"],
14
+ allow_headers=["*"],
15
+ )
16
+
17
+ CDN_HOST = "https://plapi.cdnvideohub.com"
18
+ CDN_HEADERS = {
19
+ "referer": "https://hdkino.pub/",
20
+ "origin": "https://hdkino.pub",
21
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
22
+ }
23
+ VK_HEADERS = {
24
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
25
+ "referer": "https://vkvideo.ru/",
26
+ "origin": "https://vkvideo.ru",
27
+ }
28
+
29
+
30
+ def parse_proxy_params(request: Request):
31
+ """
32
+ Парсим raw query string вручную чтобы не потерять & внутри url=
33
+ Формат: /proxy?url=ENCODED_URL или /proxy?url=ENCODED_URL&base=ENCODED_BASE
34
+ """
35
+ raw = str(request.url.query)
36
+ url = None
37
+ base = None
38
+
39
+ if raw.startswith("url="):
40
+ if "&base=" in raw:
41
+ idx = raw.index("&base=")
42
+ url = unquote(raw[4:idx])
43
+ base = unquote(raw[idx + 6:])
44
+ else:
45
+ url = unquote(raw[4:])
46
+
47
+ return url, base
48
+
49
+
50
+ def rewrite_m3u8(text: str, orig_url: str, hf_base: str) -> str:
51
+ """Переписывает все URL в m3u8 через /proxy?url="""
52
+ base = orig_url.rsplit("/", 1)[0] + "/"
53
+
54
+ def to_proxy(seg: str) -> str:
55
+ seg = seg.strip()
56
+ if not seg:
57
+ return seg
58
+ if seg.startswith("http://") or seg.startswith("https://"):
59
+ abs_url = seg
60
+ elif seg.startswith("//"):
61
+ abs_url = "https:" + seg
62
+ else:
63
+ abs_url = urljoin(base, seg)
64
+
65
+ sub_base = abs_url.rsplit("/", 1)[0] + "/"
66
+ # Всегда передаём base для правильного разрешения .ts сегментов
67
+ return f"{hf_base}/proxy?url={quote(abs_url, safe='')}&base={quote(sub_base, safe='')}"
68
+
69
+ lines = text.splitlines()
70
+ out = []
71
+ for line in lines:
72
+ stripped = line.strip()
73
+ if not stripped:
74
+ out.append(line)
75
+ elif stripped.startswith("#"):
76
+ rewritten = re.sub(r'URI="([^"]+)"', lambda m: f'URI="{to_proxy(m.group(1))}"', line)
77
+ out.append(rewritten)
78
+ else:
79
+ out.append(to_proxy(stripped))
80
+ return "\n".join(out)
81
+
82
+
83
+ @app.get("/")
84
+ async def root():
85
+ return {"status": "ok"}
86
+
87
+
88
+ @app.get("/mp4")
89
+ async def mp4_proxy(vkId: str, quality: str = "720p", request: Request = None):
90
+ """Стримит MP4 через проксі с поддержкой Range запросов"""
91
+ async with httpx.AsyncClient(timeout=15) as client:
92
+ r = await client.get(
93
+ f"{CDN_HOST}/api/v1/player/sv/video/{vkId}",
94
+ headers=CDN_HEADERS
95
+ )
96
+ data = r.json()
97
+ sources = data.get("sources") or {}
98
+
99
+ quality_map = {
100
+ "1080p": sources.get("mpegFullHdUrl"),
101
+ "720p": sources.get("mpegHighUrl"),
102
+ "480p": sources.get("mpegMediumUrl"),
103
+ "360p": sources.get("mpegLowUrl"),
104
+ "240p": sources.get("mpegLowestUrl"),
105
+ "144p": sources.get("mpegTinyUrl"),
106
+ }
107
+ url = quality_map.get(quality) or sources.get("mpegHighUrl") or sources.get("mpegFullHdUrl")
108
+ if not url:
109
+ return Response(content="not found", status_code=404)
110
+
111
+ # Передаём Range заголовок если есть (для перемотки)
112
+ req_headers = dict(VK_HEADERS)
113
+ if request and request.headers.get("range"):
114
+ req_headers["range"] = request.headers["range"]
115
+
116
+ from fastapi.responses import StreamingResponse as SR
117
+ import asyncio
118
+
119
+ async def stream_mp4():
120
+ async with httpx.AsyncClient(timeout=300) as client:
121
+ async with client.stream("GET", url, headers=req_headers) as r2:
122
+ async for chunk in r2.aiter_bytes(chunk_size=65536):
123
+ yield chunk
124
+
125
+ # Получаем заголовки без тела
126
+ async with httpx.AsyncClient(timeout=15) as client:
127
+ head = await client.head(url, headers=req_headers)
128
+
129
+ resp_headers = {
130
+ "Access-Control-Allow-Origin": "*",
131
+ "Accept-Ranges": "bytes",
132
+ "Content-Type": head.headers.get("content-type", "video/mp4"),
133
+ }
134
+ if "content-length" in head.headers:
135
+ resp_headers["Content-Length"] = head.headers["content-length"]
136
+ if "content-range" in head.headers:
137
+ resp_headers["Content-Range"] = head.headers["content-range"]
138
+
139
+ status = 206 if request and request.headers.get("range") else 200
140
+
141
+ return StreamingResponse(stream_mp4(), status_code=status, headers=resp_headers, media_type="video/mp4")
142
+
143
+
144
+ @app.get("/fresh_hls")
145
+ async def fresh_hls(vkId: str, request: Request, quality: str = ""):
146
+ """Получает свежий HLS URL для vkId — решает проблему протухших expires= URL"""
147
+ async with httpx.AsyncClient(timeout=15) as client:
148
+ r = await client.get(
149
+ f"{CDN_HOST}/api/v1/player/sv/video/{vkId}",
150
+ headers=CDN_HEADERS
151
+ )
152
+ data = r.json()
153
+ hls_url = (data.get("sources") or {}).get("hlsUrl", "")
154
+ if not hls_url:
155
+ return Response(content="#EXTM3U\n#EXT-X-ENDLIST", media_type="application/vnd.apple.mpegurl")
156
+
157
+ hf = str(request.base_url).rstrip("/").replace("http://", "https://")
158
+ base = hls_url.rsplit("/", 1)[0] + "/"
159
+
160
+ # Получаем master m3u8 и ищем нужное качество
161
+ async with httpx.AsyncClient(timeout=15) as client:
162
+ r2 = await client.get(hls_url, headers=VK_HEADERS)
163
+ master = r2.text
164
+
165
+ if not master.strip().startswith("#EXTM3U"):
166
+ proxy_url = f"{hf}/proxy?url={quote(hls_url, safe='')}"
167
+ return Response(content=f"#EXTM3U\n#EXT-X-STREAM-INF:PROGRAM-ID=1\n{proxy_url}\n",
168
+ media_type="application/vnd.apple.mpegurl",
169
+ headers={"Access-Control-Allow-Origin": "*"})
170
+
171
+ # Маппинг качеств
172
+ quality_map = {
173
+ 'ultra': '2160p', '4k': '2160p', 'quad': '1440p', '2k': '1440p',
174
+ 'full': '1080p', 'hd': '720p', 'sd': '480p', 'low': '360p',
175
+ 'lowest': '240p', 'mobile': '144p'
176
+ }
177
+ quality_order = ['2160p','1440p','1080p','720p','480p','360p','240p','144p']
178
+
179
+ # Парсим master и находим нужный sub-playlist
180
+ lines = master.splitlines()
181
+ streams = []
182
+ for i, line in enumerate(lines):
183
+ if line.startswith('#EXT-X-STREAM-INF') and i + 1 < len(lines):
184
+ next_line = lines[i + 1].strip()
185
+ if not next_line or next_line.startswith('#'):
186
+ continue
187
+ q_match = re.search(r'QUALITY=([^,\s]+)', line, re.I)
188
+ r_match = re.search(r'RESOLUTION=(\d+x\d+)', line, re.I)
189
+ label = None
190
+ if q_match:
191
+ label = quality_map.get(q_match.group(1).lower())
192
+ if not label:
193
+ n = re.match(r'\d+', q_match.group(1))
194
+ label = n.group() + 'p' if n else q_match.group(1)
195
+ elif r_match:
196
+ h = int(r_match.group(1).split('x')[1])
197
+ if h >= 2160: label = '2160p'
198
+ elif h >= 1440: label = '1440p'
199
+ elif h >= 1080: label = '1080p'
200
+ elif h >= 720: label = '720p'
201
+ elif h >= 480: label = '480p'
202
+ elif h >= 360: label = '360p'
203
+ else: label = f'{h}p'
204
+ if label:
205
+ abs_url = next_line if next_line.startswith('http') else urljoin(base, next_line)
206
+ streams.append((label, abs_url))
207
+
208
+ # Выбираем нужное качество или лучшее
209
+ target_url = None
210
+ if quality and streams:
211
+ for label, url in streams:
212
+ if label == quality:
213
+ target_url = url
214
+ break
215
+ if not target_url and streams:
216
+ # Сортируем и берём лучшее
217
+ def q_rank(item):
218
+ try: return quality_order.index(item[0])
219
+ except: return 99
220
+ streams.sort(key=q_rank)
221
+ target_url = streams[0][1]
222
+
223
+ if not target_url:
224
+ target_url = hls_url
225
+
226
+ # Проксируем выбранный sub-playlist
227
+ sub_base = target_url.rsplit("/", 1)[0] + "/"
228
+ proxy_url = f"{hf}/proxy?url={quote(target_url, safe='')}&base={quote(sub_base, safe='')}"
229
+
230
+ return Response(
231
+ content=f"#EXTM3U\n#EXT-X-STREAM-INF:PROGRAM-ID=1\n{proxy_url}\n",
232
+ media_type="application/vnd.apple.mpegurl",
233
+ headers={"Access-Control-Allow-Origin": "*"}
234
+ )
235
+ async def kp_by_imdb(imdb: str = "", title: str = "", year: str = ""):
236
+ """Ищет Кинопоиск ID по IMDB ID или названию"""
237
+ KP_API_KEY = "0319695d-7be3-4b6b-9d55-58baa6527f39"
238
+ async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
239
+ # Поиск по imdb_id
240
+ if imdb:
241
+ try:
242
+ r = await client.get(
243
+ f"https://kinopoiskapiunofficial.tech/api/v2.2/films?imdbId={imdb}",
244
+ headers={"X-API-KEY": KP_API_KEY}
245
+ )
246
+ if r.status_code == 200:
247
+ data = r.json()
248
+ items = data.get("items", [])
249
+ if items and items[0].get("kinopoiskId"):
250
+ return {"kp_id": items[0]["kinopoiskId"]}
251
+ except Exception:
252
+ pass
253
+
254
+ # П��иск по названию (fallback)
255
+ if title:
256
+ try:
257
+ r2 = await client.get(
258
+ f"https://kinopoiskapiunofficial.tech/api/v2.1/films/search-by-keyword?keyword={title}",
259
+ headers={"X-API-KEY": KP_API_KEY}
260
+ )
261
+ if r2.status_code == 200:
262
+ data2 = r2.json()
263
+ films = data2.get("films", [])
264
+ for film in films:
265
+ film_year = str(film.get("year", ""))
266
+ if not year or film_year == year or abs(int(film_year or 0) - int(year or 0)) <= 1:
267
+ kp = film.get("filmId")
268
+ if kp:
269
+ return {"kp_id": kp}
270
+ except Exception:
271
+ pass
272
+
273
+ return {"kp_id": None}
274
+
275
+
276
+ @app.get("/cdnvideohub/playlist")
277
+ async def playlist(kp: int):
278
+ async with httpx.AsyncClient(timeout=15) as client:
279
+ r = await client.get(
280
+ f"{CDN_HOST}/api/v1/player/sv/playlist?pub=12&aggr=kp&id={kp}",
281
+ headers=CDN_HEADERS
282
+ )
283
+ return Response(content=r.content, status_code=r.status_code, media_type="application/json")
284
+
285
+
286
+ @app.get("/cdnvideohub/video")
287
+ async def video(vkId: str):
288
+ async with httpx.AsyncClient(timeout=15) as client:
289
+ r = await client.get(
290
+ f"{CDN_HOST}/api/v1/player/sv/video/{vkId}",
291
+ headers=CDN_HEADERS
292
+ )
293
+ return Response(content=r.content, status_code=r.status_code, media_type="application/json")
294
+
295
+
296
+ @app.get("/proxy")
297
+ async def proxy(request: Request):
298
+ url, base_override = parse_proxy_params(request)
299
+
300
+ if not url:
301
+ return Response(content="no url", status_code=400)
302
+
303
+ # base для разрешения относительных путей
304
+ base = base_override if base_override else url.rsplit("/", 1)[0] + "/"
305
+
306
+ async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
307
+ r = await client.get(url, headers=VK_HEADERS)
308
+
309
+ content_type = r.headers.get("content-type", "application/octet-stream")
310
+ text = r.text
311
+ is_m3u8 = "mpegurl" in content_type or ".m3u8" in url.split("?")[0] or text.strip().startswith("#EXTM3U")
312
+
313
+ if not is_m3u8:
314
+ return Response(
315
+ content=r.content,
316
+ status_code=r.status_code,
317
+ media_type=content_type,
318
+ headers={"Access-Control-Allow-Origin": "*"}
319
+ )
320
+
321
+ hf = str(request.base_url).rstrip("/").replace("http://", "https://")
322
+
323
+ def to_proxy(seg: str) -> str:
324
+ seg = seg.strip()
325
+ if not seg:
326
+ return seg
327
+ if seg.startswith("http://") or seg.startswith("https://"):
328
+ abs_url = seg
329
+ elif seg.startswith("//"):
330
+ abs_url = "https:" + seg
331
+ else:
332
+ abs_url = urljoin(base, seg)
333
+ sub_base = abs_url.rsplit("/", 1)[0] + "/"
334
+ return f"{hf}/proxy?url={quote(abs_url, safe='')}&base={quote(sub_base, safe='')}"
335
+
336
+ lines = text.splitlines()
337
+ out = []
338
+ for line in lines:
339
+ stripped = line.strip()
340
+ if not stripped:
341
+ out.append(line)
342
+ elif stripped.startswith("#"):
343
+ rewritten = re.sub(r'URI="([^"]+)"', lambda m: f'URI="{to_proxy(m.group(1))}"', line)
344
+ out.append(rewritten)
345
+ else:
346
+ out.append(to_proxy(stripped))
347
+
348
+ return Response(
349
+ content="\n".join(out),
350
+ media_type="application/vnd.apple.mpegurl",
351
+ headers={"Access-Control-Allow-Origin": "*"}
352
+ )