| … | … | @@ -17,7 +17,7 @@ from typing import Optional |
| 17 | 17 | from urllib.parse import urlparse, unquote, urljoin |
| 18 | 18 | |
| 19 | 19 | import requests |
| 20 | | from flask import Flask, jsonify, request, redirect, abort, Response |
| 20 | from flask import Flask, jsonify, request, redirect, abort, Response, send_file |
| 21 | 21 | |
| 22 | 22 | NODE_HELPER = os.path.join(os.path.dirname(os.path.abspath(__file__)), |
| 23 | 23 | "node_helper", "fmovies_resolve.mjs") |
| … | … | @@ -533,7 +533,7 @@ TV_GENRES = [ |
| 533 | 533 | |
| 534 | 534 | MANIFEST = { |
| 535 | 535 | "id": "org.tabertron.vsembed", |
| 536 | | "version": "0.8.2", |
| 536 | "version": "0.9.1", |
| 537 | 537 | "name": "Tabertron VsEmbed", |
| 538 | 538 | "description": "Self-hosted HLS extractor with TMDB browse + search, played via vidcore", |
| 539 | 539 | "types": ["movie", "series"], |
| … | … | @@ -1041,6 +1041,141 @@ def vidcore_series(ident: str): |
| 1041 | 1041 | return _serve_vidcore(url) |
| 1042 | 1042 | |
| 1043 | 1043 | |
| 1044 | # --------------------------- offline MP4 copies ----------------------------- |
| 1045 | # The only way to get SEEK on this source. vidcore's HLS is muxed fMP4: Kodi's |
| 1046 | # ffmpeg demuxer plays it (audio works) but ignores seeks, and |
| 1047 | # inputstream.adaptive seeks but plays no audio because it feeds only the first |
| 1048 | # track of a muxed fMP4. Neither is fixable from here -- ISA is a compiled |
| 1049 | # binary and Kodi's seek never even reaches the demuxer. |
| 1050 | # |
| 1051 | # So hand the player something it can seek: a plain progressive MP4. The remux |
| 1052 | # is a STREAM COPY, so there is no re-encode, no quality loss and almost no CPU |
| 1053 | # -- measured on stortron at ~5x realtime (120s of video in 23.9s), ~1.33GB per |
| 1054 | # 28-minute episode at 1080p and ~0.67GB at 720p. |
| 1055 | MP4_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mp4-cache") |
| 1056 | MP4_REMUX_TIMEOUT = 45 * 60 |
| 1057 | _mp4_jobs: dict[str, str] = {} # key -> "running" | "done" | "failed: ..." |
| 1058 | _mp4_jobs_guard = threading.Lock() |
| 1059 | _mp4_sem = threading.Semaphore(2) # concurrent remuxes |
| 1060 | |
| 1061 | |
| 1062 | def _mp4_key(kind: str, ident: str, season: int, episode: int, height: int) -> str: |
| 1063 | return f"{kind}:{ident}:{season}:{episode}:{height}" |
| 1064 | |
| 1065 | |
| 1066 | def _mp4_path(kind: str, ident: str, season: int, episode: int, height: int) -> str: |
| 1067 | """Cache path for one remuxed copy. |
| 1068 | |
| 1069 | The ident is slugified rather than interpolated: it arrives from the URL and |
| 1070 | contains colons, and must never be able to walk out of the cache directory. |
| 1071 | """ |
| 1072 | slug = re.sub(r"[^A-Za-z0-9]+", "_", f"{kind}-{ident}-{season}-{episode}-{height}") |
| 1073 | return os.path.join(MP4_DIR, f"{slug}.mp4") |
| 1074 | |
| 1075 | |
| 1076 | def _mp4_ready(kind: str, ident: str, season: int, episode: int, height: int) -> bool: |
| 1077 | """True only for a COMPLETE copy. |
| 1078 | |
| 1079 | An in-progress remux writes to `<name>.part`; the moov atom that makes the |
| 1080 | file seekable is written last, so a partial file is both truncated and |
| 1081 | unseekable and must never be served. |
| 1082 | """ |
| 1083 | path = _mp4_path(kind, ident, season, episode, height) |
| 1084 | try: |
| 1085 | return os.path.getsize(path) > 0 |
| 1086 | except OSError: |
| 1087 | return False |
| 1088 | |
| 1089 | |
| 1090 | def _remux_to_mp4(variant_url: str, out_path: str) -> None: |
| 1091 | """Stream-copy an HLS variant into a progressive MP4. |
| 1092 | |
| 1093 | `-movflags +faststart` puts the moov atom at the front so the file can be |
| 1094 | seeked over HTTP. Written to `.part` and renamed, so a crashed or killed |
| 1095 | remux can never leave a half file looking complete. |
| 1096 | """ |
| 1097 | tmp = out_path + ".part" |
| 1098 | os.makedirs(MP4_DIR, exist_ok=True) |
| 1099 | proc = subprocess.run( |
| 1100 | ["ffmpeg", "-v", "error", "-y", |
| 1101 | "-headers", "Referer: https://vidcore.net/\r\n", |
| 1102 | # -f mp4 is REQUIRED: ffmpeg picks the muxer from the file extension |
| 1103 | # and the output is written to `<name>.part`, which it does not |
| 1104 | # recognise ("Error initializing the muxer ...: Invalid argument"). |
| 1105 | "-i", variant_url, "-c", "copy", "-movflags", "+faststart", |
| 1106 | "-f", "mp4", tmp], |
| 1107 | capture_output=True, text=True, timeout=MP4_REMUX_TIMEOUT, |
| 1108 | ) |
| 1109 | if proc.returncode != 0 or not os.path.exists(tmp) or os.path.getsize(tmp) == 0: |
| 1110 | raise RuntimeError(f"ffmpeg exit {proc.returncode}: {(proc.stderr or '')[-300:]}") |
| 1111 | os.replace(tmp, out_path) |
| 1112 | |
| 1113 | |
| 1114 | def _start_mp4_job(kind: str, ident: str, season: int, episode: int, height: int) -> str: |
| 1115 | """Kick off a remux in the background. Returns the job state.""" |
| 1116 | key = _mp4_key(kind, ident, season, episode, height) |
| 1117 | with _mp4_jobs_guard: |
| 1118 | state = _mp4_jobs.get(key) |
| 1119 | if state == "running": |
| 1120 | return state |
| 1121 | _mp4_jobs[key] = "running" |
| 1122 | |
| 1123 | def run() -> None: |
| 1124 | acquired = _mp4_sem.acquire(timeout=MP4_REMUX_TIMEOUT) |
| 1125 | try: |
| 1126 | if not acquired: |
| 1127 | raise RuntimeError("remux slots busy") |
| 1128 | url = _resolve_vidcore(kind, ident, season, episode) |
| 1129 | if not url: |
| 1130 | raise RuntimeError("could not resolve title") |
| 1131 | variant = _cap_variant(url, height) |
| 1132 | out = _mp4_path(kind, ident, season, episode, height) |
| 1133 | log.info("mp4 remux start: %s -> %s", key, os.path.basename(out)) |
| 1134 | started = time.time() |
| 1135 | _remux_to_mp4(variant, out) |
| 1136 | log.info("mp4 remux done: %s in %.0fs (%.2f GB)", key, |
| 1137 | time.time() - started, os.path.getsize(out) / 1e9) |
| 1138 | with _mp4_jobs_guard: |
| 1139 | _mp4_jobs[key] = "done" |
| 1140 | except Exception as exc: |
| 1141 | log.warning("mp4 remux failed for %s: %s", key, exc) |
| 1142 | with _mp4_jobs_guard: |
| 1143 | _mp4_jobs[key] = f"failed: {exc}" |
| 1144 | finally: |
| 1145 | if acquired: |
| 1146 | _mp4_sem.release() |
| 1147 | |
| 1148 | threading.Thread(target=run, name=f"mp4-{key}", daemon=True).start() |
| 1149 | return "running" |
| 1150 | |
| 1151 | |
| 1152 | def _serve_mp4(kind: str, ident: str, season: int, episode: int): |
| 1153 | height = _requested_height() |
| 1154 | if _mp4_ready(kind, ident, season, episode, height): |
| 1155 | # conditional=True is what answers Range requests, and Range is what |
| 1156 | # makes the file seekable in the player. |
| 1157 | return send_file(_mp4_path(kind, ident, season, episode, height), |
| 1158 | mimetype="video/mp4", conditional=True) |
| 1159 | state = _start_mp4_job(kind, ident, season, episode, height) |
| 1160 | # 503 rather than 404: the copy is being made, it is not an error. |
| 1161 | return Response(f"remux {state}\n", status=503, mimetype="text/plain") |
| 1162 | |
| 1163 | |
| 1164 | @app.route("/mp4/movie/<path:ident>.mp4") |
| 1165 | def mp4_movie(ident: str): |
| 1166 | if not re.fullmatch(VIDCORE_IDENT, ident): |
| 1167 | abort(404) |
| 1168 | return _serve_mp4("movie", ident, 0, 0) |
| 1169 | |
| 1170 | |
| 1171 | @app.route("/mp4/series/<path:ident>.mp4") |
| 1172 | def mp4_series(ident: str): |
| 1173 | m = re.fullmatch(rf"({VIDCORE_IDENT}):(\d+):(\d+)", ident) |
| 1174 | if not m: |
| 1175 | abort(404) |
| 1176 | return _serve_mp4("series", m.group(1), int(m.group(2)), int(m.group(3))) |
| 1177 | |
| 1178 | |
| 1044 | 1179 | def _vidcore_ident_for_fmv(ctype: str, slug: str) -> Optional[str]: |
| 1045 | 1180 | """fmovies carries no imdb/tmdb id — bridge its slug to TMDB so the (lazy) |
| 1046 | 1181 | vidcore row can be offered on catalog entries too.""" |
| … | … | @@ -1052,6 +1187,33 @@ def _vidcore_ident_for_fmv(ctype: str, slug: str) -> Optional[str]: |
| 1052 | 1187 | return f"tmdb:{tmdb}" |
| 1053 | 1188 | |
| 1054 | 1189 | |
| 1190 | def _append_mp4_row(out: list, kind: str, ident: Optional[str], |
| 1191 | season: int = 0, episode: int = 0) -> None: |
| 1192 | """Offer a local remuxed copy, FIRST, when one exists. |
| 1193 | |
| 1194 | Only listed when the copy is complete, so the row can never be a dead click: |
| 1195 | it is the one row on this source where seek, resume and pause all work, and |
| 1196 | it plays from local disk with no CDN token to expire. |
| 1197 | """ |
| 1198 | if not ident: |
| 1199 | return |
| 1200 | for height in VIDCORE_HEIGHTS: |
| 1201 | if not _mp4_ready(kind, ident, season, episode, height): |
| 1202 | continue |
| 1203 | if kind == "series": |
| 1204 | path = f"/mp4/series/{ident}:{season}:{episode}.mp4" |
| 1205 | tag = f"S{season:02d}E{episode:02d} " |
| 1206 | else: |
| 1207 | path = f"/mp4/movie/{ident}.mp4" |
| 1208 | tag = "" |
| 1209 | out.append(_stream_dict(Stream( |
| 1210 | url=f"http://{request.host}{path}?h={height}", |
| 1211 | quality=f"{height}p", name="local", |
| 1212 | referer="https://vidcore.net/", |
| 1213 | title_extra=f"{tag}{height}p seekable\nlocal copy"))) |
| 1214 | return |
| 1215 | |
| 1216 | |
| 1055 | 1217 | def _append_vidcore_row(out: list, kind: str, ident: Optional[str], |
| 1056 | 1218 | season: int = 0, episode: int = 0) -> None: |
| 1057 | 1219 | """Additive lazy vidcore rows — one per quality, hidden for known misses. |
| … | … | @@ -1103,6 +1265,7 @@ def stream_movie(ident: str): |
| 1103 | 1265 | fxl = re.fullmatch(r"fxl:(\d+)", ident) |
| 1104 | 1266 | if fxl: # flixlab-native catalog: vidcore only |
| 1105 | 1267 | out: list = [] |
| 1268 | _append_mp4_row(out, "movie", f"tmdb:{fxl.group(1)}") |
| 1106 | 1269 | _append_vidcore_row(out, "movie", f"tmdb:{fxl.group(1)}") |
| 1107 | 1270 | return jsonify({"streams": out}) |
| 1108 | 1271 | fmv = re.fullmatch(r"fmv:(\d+):([a-z0-9\-]+)", ident) |
| … | … | @@ -1134,6 +1297,7 @@ def stream_series(ident: str): |
| 1134 | 1297 | if fxl: # flixlab-native catalog: vidcore only |
| 1135 | 1298 | tmdb, season, episode = fxl.group(1), int(fxl.group(2)), int(fxl.group(3)) |
| 1136 | 1299 | out: list = [] |
| 1300 | _append_mp4_row(out, "series", f"tmdb:{tmdb}", season, episode) |
| 1137 | 1301 | _append_vidcore_row(out, "series", f"tmdb:{tmdb}", season, episode) |
| 1138 | 1302 | return jsonify({"streams": out}) |
| 1139 | 1303 | fmv = re.fullmatch(r"fmv:(\d+):([a-z0-9\-]+):(\d+):(\d+)", ident) |