feat: add client-side parsing players with Video.js

- Added client-side parsing for Vidsrc and Vidlink
- Custom Video.js player with HLS support
- Auto-detection of m3u8/mp4 streams from iframe
- New routes: /players/vidsrc-parse, /players/vidlink-parse
- Performance API monitoring for stream detection
- Fallback to original iframe if parsing fails
- Updated API documentation
This commit is contained in:
Cursor Agent
2025-10-04 20:21:55 +00:00
parent 1872222346
commit 1db7391533
4 changed files with 539 additions and 0 deletions

View File

@@ -100,6 +100,11 @@ func Handler(w http.ResponseWriter, r *http.Request) {
api.HandleFunc("/players/vidsrc/{media_type}/{imdb_id}", playersHandler.GetVidsrcPlayer).Methods("GET")
api.HandleFunc("/players/vidlink/movie/{imdb_id}", playersHandler.GetVidlinkMoviePlayer).Methods("GET")
api.HandleFunc("/players/vidlink/tv/{tmdb_id}", playersHandler.GetVidlinkTVPlayer).Methods("GET")
// Client-side parsing players (custom player with HLS support)
api.HandleFunc("/players/vidsrc-parse/{media_type}/{imdb_id}", playersHandler.GetVidsrcParserPlayer).Methods("GET")
api.HandleFunc("/players/vidlink-parse/movie/{imdb_id}", playersHandler.GetVidlinkParserMoviePlayer).Methods("GET")
api.HandleFunc("/players/vidlink-parse/tv/{tmdb_id}", playersHandler.GetVidlinkParserTVPlayer).Methods("GET")
api.HandleFunc("/players/rgshows/{tmdb_id}", playersHandler.GetRgShowsPlayer).Methods("GET")
api.HandleFunc("/players/rgshows/{tmdb_id}/{season}/{episode}", playersHandler.GetRgShowsTVPlayer).Methods("GET")
api.HandleFunc("/players/iframevideo/{kinopoisk_id}/{imdb_id}", playersHandler.GetIframeVideoPlayer).Methods("GET")

View File

@@ -82,6 +82,11 @@ func main() {
api.HandleFunc("/players/vidlink/movie/{imdb_id}", playersHandler.GetVidlinkMoviePlayer).Methods("GET")
api.HandleFunc("/players/vidlink/tv/{tmdb_id}", playersHandler.GetVidlinkTVPlayer).Methods("GET")
// Client-side parsing players (custom player with HLS support)
api.HandleFunc("/players/vidsrc-parse/{media_type}/{imdb_id}", playersHandler.GetVidsrcParserPlayer).Methods("GET")
api.HandleFunc("/players/vidlink-parse/movie/{imdb_id}", playersHandler.GetVidlinkParserMoviePlayer).Methods("GET")
api.HandleFunc("/players/vidlink-parse/tv/{tmdb_id}", playersHandler.GetVidlinkParserTVPlayer).Methods("GET")
api.HandleFunc("/torrents/search/{imdbId}", torrentsHandler.SearchTorrents).Methods("GET")
api.HandleFunc("/torrents/movies", torrentsHandler.SearchMovies).Methods("GET")
api.HandleFunc("/torrents/series", torrentsHandler.SearchSeries).Methods("GET")

View File

@@ -0,0 +1,419 @@
package handlers
import (
"fmt"
"log"
"net/http"
"github.com/gorilla/mux"
)
// GetVidsrcParserPlayer handles Vidsrc.to player with client-side parsing
func (h *PlayersHandler) GetVidsrcParserPlayer(w http.ResponseWriter, r *http.Request) {
log.Printf("GetVidsrcParserPlayer called: %s %s", r.Method, r.URL.Path)
vars := mux.Vars(r)
imdbId := vars["imdb_id"]
mediaType := vars["media_type"] // "movie" or "tv"
if imdbId == "" || mediaType == "" {
http.Error(w, "imdb_id and media_type are required", http.StatusBadRequest)
return
}
var embedURL string
if mediaType == "movie" {
embedURL = fmt.Sprintf("https://vidsrc.to/embed/movie/%s", imdbId)
} else if mediaType == "tv" {
season := r.URL.Query().Get("season")
episode := r.URL.Query().Get("episode")
if season == "" || episode == "" {
http.Error(w, "season and episode are required for TV shows", http.StatusBadRequest)
return
}
embedURL = fmt.Sprintf("https://vidsrc.to/embed/tv/%s/%s/%s", imdbId, season, episode)
} else {
http.Error(w, "Invalid media_type. Use 'movie' or 'tv'", http.StatusBadRequest)
return
}
log.Printf("Generated Vidsrc embed URL: %s", embedURL)
htmlDoc := generateClientParserHTML(embedURL, "Vidsrc Player")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(htmlDoc))
log.Printf("Successfully served Vidsrc parser player for %s: %s", mediaType, imdbId)
}
// GetVidlinkParserMoviePlayer handles Vidlink.pro parser for movies
func (h *PlayersHandler) GetVidlinkParserMoviePlayer(w http.ResponseWriter, r *http.Request) {
log.Printf("GetVidlinkParserMoviePlayer called: %s %s", r.Method, r.URL.Path)
vars := mux.Vars(r)
imdbId := vars["imdb_id"]
if imdbId == "" {
http.Error(w, "imdb_id is required", http.StatusBadRequest)
return
}
embedURL := fmt.Sprintf("https://vidlink.pro/movie/%s", imdbId)
log.Printf("Generated Vidlink movie embed URL: %s", embedURL)
htmlDoc := generateClientParserHTML(embedURL, "Vidlink Player")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(htmlDoc))
log.Printf("Successfully served Vidlink parser movie player: %s", imdbId)
}
// GetVidlinkParserTVPlayer handles Vidlink.pro parser for TV shows
func (h *PlayersHandler) GetVidlinkParserTVPlayer(w http.ResponseWriter, r *http.Request) {
log.Printf("GetVidlinkParserTVPlayer called: %s %s", r.Method, r.URL.Path)
vars := mux.Vars(r)
tmdbId := vars["tmdb_id"]
if tmdbId == "" {
http.Error(w, "tmdb_id is required", http.StatusBadRequest)
return
}
season := r.URL.Query().Get("season")
episode := r.URL.Query().Get("episode")
if season == "" || episode == "" {
http.Error(w, "season and episode are required for TV shows", http.StatusBadRequest)
return
}
embedURL := fmt.Sprintf("https://vidlink.pro/tv/%s/%s/%s", tmdbId, season, episode)
log.Printf("Generated Vidlink TV embed URL: %s", embedURL)
htmlDoc := generateClientParserHTML(embedURL, "Vidlink Player")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(htmlDoc))
log.Printf("Successfully served Vidlink parser TV player: %s S%sE%s", tmdbId, season, episode)
}
func generateClientParserHTML(embedURL, title string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/video.js@8.6.1/dist/video-js.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #000;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
#status {
position: fixed;
top: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 10px 15px;
border-radius: 8px;
font-size: 14px;
z-index: 10000;
max-width: 90%%;
transition: opacity 0.3s;
}
#status.success {
background: rgba(34, 197, 94, 0.9);
}
#status.error {
background: rgba(239, 68, 68, 0.9);
}
#loader {
position: fixed;
top: 50%%;
left: 50%%;
transform: translate(-50%%, -50%%);
text-align: center;
z-index: 9999;
}
.spinner {
border: 4px solid rgba(255, 255, 255, 0.1);
border-top: 4px solid #fff;
border-radius: 50%%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin {
0%% { transform: rotate(0deg); }
100%% { transform: rotate(360deg); }
}
#loader-text {
color: #fff;
font-size: 14px;
}
#hidden-iframe {
position: absolute;
width: 0;
height: 0;
border: 0;
opacity: 0;
pointer-events: none;
}
#player-container {
width: 100vw;
height: 100vh;
display: none;
}
#player-container.active {
display: block;
}
.video-js {
width: 100%%;
height: 100%%;
}
.vjs-big-play-button {
left: 50%%;
top: 50%%;
transform: translate(-50%%, -50%%);
}
</style>
</head>
<body>
<div id="status">Инициализация плеера...</div>
<div id="loader">
<div class="spinner"></div>
<div id="loader-text">Загрузка видео...</div>
</div>
<iframe id="hidden-iframe" src="%s" sandbox="allow-scripts allow-same-origin"></iframe>
<div id="player-container">
<video id="video-player" class="video-js vjs-default-skin vjs-big-play-centered" controls preload="auto">
<p class="vjs-no-js">
Для воспроизведения видео включите JavaScript или используйте браузер с поддержкой HTML5.
</p>
</video>
</div>
<script src="https://cdn.jsdelivr.net/npm/video.js@8.6.1/dist/video.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@videojs/http-streaming@3.8.0/dist/videojs-http-streaming.min.js"></script>
<script>
const status = document.getElementById('status');
const loader = document.getElementById('loader');
const playerContainer = document.getElementById('player-container');
const iframe = document.getElementById('hidden-iframe');
let foundUrls = new Set();
let player = null;
let checkTimeout = null;
function updateStatus(message, type = 'info') {
status.textContent = message;
status.className = type;
console.log('[Parser]', message);
}
function hideLoader() {
loader.style.display = 'none';
}
function showPlayer(url) {
if (foundUrls.has(url)) return;
foundUrls.add(url);
console.log('[Parser] Found stream URL:', url);
updateStatus('Видео найдено! Запуск...', 'success');
setTimeout(() => {
hideLoader();
playerContainer.classList.add('active');
iframe.style.display = 'none';
if (player) {
player.dispose();
}
player = videojs('video-player', {
controls: true,
autoplay: true,
preload: 'auto',
fluid: false,
fill: true,
responsive: true,
html5: {
vhs: {
overrideNative: true,
enableLowInitialPlaylist: true,
smoothQualityChange: true,
fastQualityChange: true
},
nativeAudioTracks: false,
nativeVideoTracks: false
}
});
player.src({
src: url,
type: url.includes('.m3u8') ? 'application/x-mpegURL' : 'video/mp4'
});
player.ready(function() {
updateStatus('Воспроизведение...', 'success');
setTimeout(() => {
status.style.opacity = '0';
}, 3000);
});
player.on('error', function(e) {
const error = player.error();
console.error('[Parser] Player error:', error);
updateStatus('Ошибка воспроизведения: ' + (error ? error.message : 'Unknown'), 'error');
});
}, 500);
}
// Intercept fetch requests from iframe
const originalFetch = window.fetch;
window.fetch = function(...args) {
const url = args[0];
if (typeof url === 'string') {
if (url.includes('.m3u8') || url.includes('.mp4')) {
console.log('[Parser] Intercepted fetch:', url);
showPlayer(url);
}
}
return originalFetch.apply(this, args);
};
// Monitor iframe network activity using Performance API
let lastCheck = Date.now();
function checkPerformance() {
try {
const entries = performance.getEntriesByType('resource');
const newEntries = entries.filter(e => e.startTime > lastCheck);
newEntries.forEach(entry => {
const url = entry.name;
if (url.includes('.m3u8') || (url.includes('.mp4') && !url.includes('poster'))) {
console.log('[Parser] Performance API detected:', url);
showPlayer(url);
}
});
lastCheck = Date.now();
} catch (e) {
console.error('[Parser] Performance check error:', e);
}
}
// Check performance entries every 500ms
setInterval(checkPerformance, 500);
// Listen for messages from iframe (if we can inject script there)
window.addEventListener('message', function(event) {
try {
if (event.data && typeof event.data === 'object') {
if (event.data.type === 'stream-url' && event.data.url) {
console.log('[Parser] Message from iframe:', event.data.url);
showPlayer(event.data.url);
}
}
} catch (e) {
console.error('[Parser] Message handler error:', e);
}
});
// Inject monitoring script into iframe (may not work due to CORS)
iframe.addEventListener('load', function() {
try {
console.log('[Parser] Iframe loaded, attempting to inject monitor...');
updateStatus('Поиск видео...', 'info');
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
const script = iframeDoc.createElement('script');
script.textContent = ` + "`" + `
(function() {
const originalFetch = window.fetch;
window.fetch = function(...args) {
const url = args[0];
if (typeof url === 'string' && (url.includes('.m3u8') || url.includes('.mp4'))) {
window.parent.postMessage({ type: 'stream-url', url: url }, '*');
}
return originalFetch.apply(this, args);
};
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url) {
if (typeof url === 'string' && (url.includes('.m3u8') || url.includes('.mp4'))) {
window.parent.postMessage({ type: 'stream-url', url: url }, '*');
}
return originalOpen.apply(this, arguments);
};
})();
` + "`" + `;
iframeDoc.head.appendChild(script);
console.log('[Parser] Monitor script injected successfully');
} catch (e) {
console.warn('[Parser] Could not inject into iframe (CORS):', e.message);
updateStatus('Мониторинг через Performance API...', 'info');
}
});
// Timeout if nothing found in 30 seconds
checkTimeout = setTimeout(() => {
if (foundUrls.size === 0) {
updateStatus('Не удалось найти видео. Попробуйте обновить страницу.', 'error');
hideLoader();
// Fallback: show iframe directly
iframe.style.width = '100%%';
iframe.style.height = '100%%';
iframe.style.opacity = '1';
iframe.style.pointerEvents = 'auto';
}
}, 30000);
// Cleanup
window.addEventListener('beforeunload', function() {
if (player) {
player.dispose();
}
if (checkTimeout) {
clearTimeout(checkTimeout);
}
});
console.log('[Parser] Client-side parser initialized');
console.log('[Parser] Target URL: %s');
</script>
</body>
</html>`, title, embedURL, embedURL)
}

View File

@@ -484,6 +484,116 @@ func getOpenAPISpecWithURL(baseURL string) *OpenAPISpec {
},
},
},
"/api/v1/players/vidsrc-parse/{media_type}/{imdb_id}": map[string]interface{}{
"get": map[string]interface{}{
"summary": "Vidsrc плеер с парсингом (кастомный плеер)",
"description": "Возвращает HTML-страницу с кастомным Video.js плеером. Автоматически извлекает m3u8 ссылку из Vidsrc.to через клиентский парсинг в iframe. Использует IMDb ID для фильмов и сериалов.",
"tags": []string{"Players"},
"parameters": []map[string]interface{}{
{
"name": "media_type",
"in": "path",
"required": true,
"schema": map[string]interface{}{"type": "string", "enum": []string{"movie", "tv"}},
"description": "Тип контента: movie (фильм) или tv (сериал)",
},
{
"name": "imdb_id",
"in": "path",
"required": true,
"schema": map[string]string{"type": "string"},
"description": "IMDb ID, например tt6385540 (с префиксом tt)",
},
{
"name": "season",
"in": "query",
"required": false,
"schema": map[string]string{"type": "integer"},
"description": "Номер сезона (обязательно для TV)",
},
{
"name": "episode",
"in": "query",
"required": false,
"schema": map[string]string{"type": "integer"},
"description": "Номер серии (обязательно для TV)",
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "HTML с кастомным Video.js плеером и системой парсинга",
"content": map[string]interface{}{
"text/html": map[string]interface{}{},
},
},
"400": map[string]interface{}{"description": "Отсутствуют обязательные параметры"},
},
},
},
"/api/v1/players/vidlink-parse/movie/{imdb_id}": map[string]interface{}{
"get": map[string]interface{}{
"summary": "Vidlink плеер с парсингом для фильмов (кастомный)",
"description": "Возвращает HTML-страницу с кастомным Video.js плеером. Автоматически извлекает m3u8/mp4 ссылку из Vidlink.pro через клиентский парсинг. Использует IMDb ID для фильмов.",
"tags": []string{"Players"},
"parameters": []map[string]interface{}{
{
"name": "imdb_id",
"in": "path",
"required": true,
"schema": map[string]string{"type": "string"},
"description": "IMDb ID фильма, например tt1234567 (с префиксом tt)",
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "HTML с кастомным Video.js плеером и системой парсинга",
"content": map[string]interface{}{
"text/html": map[string]interface{}{},
},
},
"400": map[string]interface{}{"description": "IMDb ID не указан"},
},
},
},
"/api/v1/players/vidlink-parse/tv/{tmdb_id}": map[string]interface{}{
"get": map[string]interface{}{
"summary": "Vidlink плеер с парсингом для сериалов (кастомный)",
"description": "Возвращает HTML-страницу с кастомным Video.js плеером. Автоматически извлекает m3u8/mp4 ссылку из Vidlink.pro через клиентский парсинг. Использует TMDB ID для сериалов.",
"tags": []string{"Players"},
"parameters": []map[string]interface{}{
{
"name": "tmdb_id",
"in": "path",
"required": true,
"schema": map[string]string{"type": "integer"},
"description": "TMDB ID сериала, например 94997 (числовой идентификатор без префикса)",
},
{
"name": "season",
"in": "query",
"required": true,
"schema": map[string]string{"type": "integer"},
"description": "Номер сезона (обязательно)",
},
{
"name": "episode",
"in": "query",
"required": true,
"schema": map[string]string{"type": "integer"},
"description": "Номер серии (обязательно)",
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "HTML с кастомным Video.js плеером и системой парсинга",
"content": map[string]interface{}{
"text/html": map[string]interface{}{},
},
},
"400": map[string]interface{}{"description": "Отсутствуют обязательные параметры (tmdb_id, season, episode)"},
},
},
},
"/api/v1/torrents/search/{imdbId}": map[string]interface{}{