diff --git a/.gitignore b/.gitignore index 3c2e151..41155a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .env .env.local node_modules -package-lock.json \ No newline at end of file +package-lock.json +yarn.lock \ No newline at end of file diff --git a/src/index.js b/src/index.js index 4a714eb..4372295 100644 --- a/src/index.js +++ b/src/index.js @@ -58,6 +58,10 @@ const swaggerOptions = { { name: 'players', description: 'Плееры Alloha и Lumex' + }, + { + name: 'torrents', + description: 'Поиск торрентов' } ], components: { @@ -261,6 +265,7 @@ const reactionsRouter = require('./routes/reactions'); const routerToUse = reactionsRouter.default || reactionsRouter; require('./utils/cleanup'); const authRouter = require('./routes/auth'); +const torrentsRouter = require('./routes/torrents'); app.use('/movies', moviesRouter); app.use('/tv', tvRouter); @@ -270,6 +275,7 @@ app.use('/favorites', favoritesRouter); app.use('/players', playersRouter); app.use('/reactions', routerToUse); app.use('/auth', authRouter); +app.use('/torrents', torrentsRouter); /** * @swagger diff --git a/src/routes/torrents.js b/src/routes/torrents.js new file mode 100644 index 0000000..99d2686 --- /dev/null +++ b/src/routes/torrents.js @@ -0,0 +1,469 @@ +const express = require('express'); +const router = express.Router(); +const TorrentService = require('../services/torrent.service'); + +// Создаем экземпляр сервиса +const torrentService = new TorrentService(); + +// Middleware для логирования запросов +router.use((req, res, next) => { + console.log('Torrents API Request:', { + method: req.method, + path: req.path, + query: req.query, + params: req.params + }); + next(); +}); + +/** + * @swagger + * /torrents/search/{imdbId}: + * get: + * summary: Поиск торрентов по IMDB ID + * description: Поиск торрентов для фильма или сериала по его IMDB ID через bitru.org + * tags: [torrents] + * parameters: + * - in: path + * name: imdbId + * required: true + * description: IMDB ID фильма/сериала (например, tt1234567) + * schema: + * type: string + * - in: query + * name: type + * required: false + * description: Тип контента (movie или tv) + * schema: + * type: string + * enum: [movie, tv] + * default: movie + * - in: query + * name: quality + * required: false + * description: Желаемое качество (например, 1080p, 4K). Можно указать несколько. + * schema: + * type: array + * items: + * type: string + * - in: query + * name: minQuality + * required: false + * description: Минимальное качество. + * schema: + * type: string + * enum: ['360p', '480p', '720p', '1080p', '1440p', '2160p'] + * - in: query + * name: maxQuality + * required: false + * description: Максимальное качество. + * schema: + * type: string + * enum: ['360p', '480p', '720p', '1080p', '1440p', '2160p'] + * - in: query + * name: excludeQualities + * required: false + * description: Исключить качества. Можно указать несколько. + * schema: + * type: array + * items: + * type: string + * - in: query + * name: hdr + * required: false + * description: Фильтр по наличию HDR. + * schema: + * type: boolean + * - in: query + * name: hevc + * required: false + * description: Фильтр по наличию HEVC/H.265. + * schema: + * type: boolean + * - in: query + * name: sortBy + * required: false + * description: Поле для сортировки. + * schema: + * type: string + * enum: [seeders, size, date] + * default: seeders + * - in: query + * name: sortOrder + * required: false + * description: Порядок сортировки. + * schema: + * type: string + * enum: [asc, desc] + * default: desc + * - in: query + * name: groupByQuality + * required: false + * description: Группировать результаты по качеству. + * schema: + * type: boolean + * default: false + * - in: query + * name: season + * required: false + * description: Номер сезона для сериалов. + * schema: + * type: integer + * minimum: 1 + * - in: query + * name: groupBySeason + * required: false + * description: Группировать результаты по сезону (только для сериалов). + * schema: + * type: boolean + * default: false + * responses: + * 200: + * description: Успешный ответ с результатами поиска. + * content: + * application/json: + * schema: + * type: object + * properties: + * imdbId: + * type: string + * type: + * type: string + * total: + * type: integer + * grouped: + * type: boolean + * results: + * oneOf: + * - type: array + * items: + * $ref: '#/components/schemas/Torrent' + * - type: object + * properties: + * '4K': + * type: array + * items: + * $ref: '#/components/schemas/Torrent' + * '1080p': + * type: array + * items: + * $ref: '#/components/schemas/Torrent' + * '720p': + * type: array + * items: + * $ref: '#/components/schemas/Torrent' + * 400: + * description: Неверный запрос + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * description: Описание ошибки + * 404: + * description: Контент не найден + * 500: + * description: Ошибка сервера + */ +router.get('/search/:imdbId', async (req, res) => { + try { + const { imdbId } = req.params; + const { + type = 'movie', + quality, + minQuality, + maxQuality, + excludeQualities, + hdr, + hevc, + sortBy = 'seeders', + sortOrder = 'desc', + groupByQuality = false, + season, + groupBySeason = false + } = req.query; + + // Валидация IMDB ID + if (!imdbId || !imdbId.match(/^tt\d+$/)) { + return res.status(400).json({ + error: 'Invalid IMDB ID format. Expected format: tt1234567' + }); + } + + // Валидация типа контента + if (!['movie', 'tv'].includes(type)) { + return res.status(400).json({ + error: 'Invalid type. Must be "movie" or "tv"' + }); + } + + console.log('Torrent search request:', { imdbId, type, quality, season, groupByQuality, groupBySeason }); + + // Поиск торрентов с учетом сезона для сериалов + const searchOptions = { season: season ? parseInt(season) : null }; + const results = await torrentService.searchTorrentsByImdbId(req.tmdb, imdbId, type, searchOptions); + console.log(`Found ${results.length} torrents for IMDB ID: ${imdbId}`); + + // Если результатов нет, возвращаем 404 + if (results.length === 0) { + return res.status(404).json({ + error: 'No torrents found for this IMDB ID', + imdbId, + type + }); + } + + // Применяем фильтрацию по качеству, если указаны параметры + let filteredResults = results; + const qualityFilter = {}; + + if (quality) { + qualityFilter.qualities = Array.isArray(quality) ? quality : [quality]; + } + if (minQuality) qualityFilter.minQuality = minQuality; + if (maxQuality) qualityFilter.maxQuality = maxQuality; + if (excludeQualities) { + qualityFilter.excludeQualities = Array.isArray(excludeQualities) ? excludeQualities : [excludeQualities]; + } + if (hdr !== undefined) qualityFilter.hdr = hdr === 'true'; + if (hevc !== undefined) qualityFilter.hevc = hevc === 'true'; + + // Применяем фильтрацию, если есть параметры качества + if (Object.keys(qualityFilter).length > 0) { + const redApiClient = torrentService.redApiClient; + filteredResults = redApiClient.filterByQuality(results, qualityFilter); + console.log(`Filtered to ${filteredResults.length} torrents by quality`); + } + + // Группировка или обычная сортировка + let responseData; + const redApiClient = torrentService.redApiClient; + + if (groupBySeason === 'true' || groupBySeason === true) { + // Группируем по сезону (только для сериалов) + if (type === 'tv') { + const groupedResults = redApiClient.groupBySeason(filteredResults); + responseData = { + imdbId, + type, + total: filteredResults.length, + grouped: true, + groupedBy: 'season', + results: groupedResults + }; + } else { + return res.status(400).json({ + error: 'Season grouping is only available for TV series (type=tv)' + }); + } + } else if (groupByQuality === 'true' || groupByQuality === true) { + // Группируем по качеству + const groupedResults = redApiClient.groupByQuality(filteredResults); + + responseData = { + imdbId, + type, + total: filteredResults.length, + grouped: true, + groupedBy: 'quality', + results: groupedResults + }; + } else { + // Обычная сортировка + const redApiClient = torrentService.redApiClient; + const sortedResults = redApiClient.sortTorrents(filteredResults, sortBy, sortOrder); + + responseData = { + imdbId, + type, + total: filteredResults.length, + grouped: false, + season: season ? parseInt(season) : null, + results: sortedResults + }; + } + + console.log('Torrent search response:', { + imdbId, + type, + results_count: filteredResults.length, + grouped: responseData.grouped + }); + + res.json(responseData); + } catch (error) { + console.error('Error searching torrents:', error); + + // Проверяем, является ли это ошибкой "не найдено" + if (error.message.includes('not found')) { + return res.status(404).json({ + error: 'Movie/TV show not found', + details: error.message + }); + } + + res.status(500).json({ + error: 'Failed to search torrents', + details: error.message + }); + } +}); + +/** + * @swagger + * /torrents/search: + * get: + * summary: Поиск торрентов по названию + * description: Прямой поиск торрентов по названию на bitru.org + * tags: [torrents] + * parameters: + * - in: query + * name: query + * required: true + * description: Поисковый запрос + * schema: + * type: string + * example: Матрица + * - in: query + * name: category + * description: Категория поиска (1 - фильмы, 2 - сериалы) + * schema: + * type: string + * enum: ['1', '2'] + * default: '1' + * example: '1' + * responses: + * 200: + * description: Результаты поиска + * content: + * application/json: + * schema: + * type: object + * properties: + * query: + * type: string + * description: Поисковый запрос + * category: + * type: string + * description: Категория поиска + * results: + * type: array + * items: + * type: object + * properties: + * name: + * type: string + * url: + * type: string + * size: + * type: string + * seeders: + * type: integer + * leechers: + * type: integer + * source: + * type: string + * 400: + * description: Неверный запрос + * 500: + * description: Ошибка сервера + */ +router.get('/search', async (req, res) => { + try { + const { query, category = '1' } = req.query; + + if (!query) { + return res.status(400).json({ + error: 'Query parameter is required' + }); + } + + if (!['1', '2'].includes(category)) { + return res.status(400).json({ + error: 'Invalid category. Must be "1" (movies) or "2" (tv shows)' + }); + } + + console.log('Direct torrent search request:', { query, category }); + + const results = await torrentService.searchTorrents(query, category); + + console.log('Direct torrent search response:', { + query, + category, + results_count: results.length + }); + + res.json({ + query, + category, + results + }); + } catch (error) { + console.error('Error in direct torrent search:', error); + res.status(500).json({ + error: 'Failed to search torrents', + details: error.message + }); + } +}); + +/** + * @swagger + * /torrents/health: + * get: + * summary: Проверка работоспособности торрент-сервиса + * description: Проверяет доступность bitru.org + * tags: [torrents] + * responses: + * 200: + * description: Сервис работает + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: ok + * timestamp: + * type: string + * format: date-time + * source: + * type: string + * example: bitru.org + * 500: + * description: Сервис недоступен + */ +router.get('/health', async (req, res) => { + try { + const axios = require('axios'); + + // Проверяем доступность bitru.org + const response = await axios.get('https://bitru.org', { + timeout: 5000, + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + }); + + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + source: 'bitru.org', + statusCode: response.status + }); + } catch (error) { + console.error('Health check failed:', error); + res.status(500).json({ + status: 'error', + timestamp: new Date().toISOString(), + source: 'bitru.org', + error: error.message + }); + } +}); + +module.exports = router; diff --git a/src/services/redapi.service.js b/src/services/redapi.service.js new file mode 100644 index 0000000..4ede7d9 --- /dev/null +++ b/src/services/redapi.service.js @@ -0,0 +1,759 @@ +const axios = require('axios'); + +/** + * Клиент для работы с RedAPI (Lampac) + * Основан на коде Lampac ApiController.cs и RedApi.cs + */ +class RedApiClient { + constructor(baseUrl = 'http://redapi.cfhttp.top', apikey = '') { + this.baseUrl = baseUrl; + this.apikey = apikey; + } + + /** + * Поиск торрентов через RedAPI с поддержкой сезонов + * @param {Object} params - параметры поиска + * @returns {Promise} + */ + async searchTorrents(params) { + const { + query, // поисковый запрос + title, // название фильма/сериала + title_original, // оригинальное название + year, // год выпуска + is_serial, // тип контента: 1-фильм, 2-сериал, 5-аниме + category, // категория + imdb, // IMDB ID + season // номер сезона для сериалов + } = params; + + const searchParams = new URLSearchParams(); + + if (query) searchParams.append('query', query); + if (title) searchParams.append('title', title); + if (title_original) searchParams.append('title_original', title_original); + if (year) searchParams.append('year', year); + if (is_serial) searchParams.append('is_serial', is_serial); + if (category) searchParams.append('category[]', category); + if (imdb) searchParams.append('imdb', imdb); + if (season) searchParams.append('season', season); + if (this.apikey) searchParams.append('apikey', this.apikey); + + try { + console.log('RedAPI search params:', params); + console.log('Search URL:', `${this.baseUrl}/api/v2.0/indexers/all/results?${searchParams}`); + + const response = await axios.get( + `${this.baseUrl}/api/v2.0/indexers/all/results?${searchParams}`, + { timeout: 8000 } + ); + + return this.parseResults(response.data); + } catch (error) { + console.error('RedAPI search error:', error.message); + return []; + } + } + + /** + * Парсинг результатов поиска с поддержкой сезонов + * @param {Object} data - ответ от RedAPI + * @returns {Array} + */ + parseResults(data) { + if (!data.Results || !Array.isArray(data.Results)) { + console.log('RedAPI: No results found or invalid response format'); + return []; + } + + console.log(`RedAPI: Found ${data.Results.length} results`); + + return data.Results.map(torrent => ({ + title: torrent.Title, + tracker: torrent.Tracker, + size: torrent.Size, + seeders: torrent.Seeders, + peers: torrent.Peers, + magnet: torrent.MagnetUri, + publishDate: torrent.PublishDate, + category: torrent.CategoryDesc, + quality: torrent.Info?.quality, + voice: torrent.Info?.voices, + details: torrent.Details, + types: torrent.Info?.types, + seasons: torrent.Info?.seasons, // Добавляем информацию о сезонах + source: 'RedAPI' + })); + } + + /** + * Фильтрация результатов по типу контента на клиенте + * Решает проблему смешанных результатов от API + * @param {Array} results - результаты поиска + * @param {string} contentType - тип контента (movie/serial/anime) + * @returns {Array} + */ + filterByContentType(results, contentType) { + return results.filter(torrent => { + // Фильтрация по полю types, если оно есть + if (torrent.types && Array.isArray(torrent.types)) { + switch (contentType) { + case 'movie': + return torrent.types.some(type => + ['movie', 'multfilm', 'documovie'].includes(type) + ); + case 'serial': + return torrent.types.some(type => + ['serial', 'multserial', 'docuserial', 'tvshow'].includes(type) + ); + case 'anime': + return torrent.types.includes('anime'); + } + } + + // Фильтрация по названию, если types недоступно + const title = torrent.title.toLowerCase(); + switch (contentType) { + case 'movie': + return !/(сезон|серии|series|season|эпизод)/i.test(title); + case 'serial': + return /(сезон|серии|series|season|эпизод)/i.test(title); + case 'anime': + return torrent.category === 'TV/Anime' || /anime/i.test(title); + default: + return true; + } + }); + } + + /** + * Поиск фильмов с дополнительной фильтрацией + * @param {string} title - название на русском + * @param {string} originalTitle - оригинальное название + * @param {number} year - год выпуска + * @returns {Promise} + */ + async searchMovies(title, originalTitle, year) { + const results = await this.searchTorrents({ + title, + title_original: originalTitle, + year, + is_serial: 1, + category: '2000' + }); + + return this.filterByContentType(results, 'movie'); + } + + /** + * Поиск сериалов с дополнительной фильтрацией + * @param {string} title - название на русском + * @param {string} originalTitle - оригинальное название + * @param {number} year - год выпуска + * @returns {Promise} + */ + async searchSeries(title, originalTitle, year) { + const results = await this.searchTorrents({ + title, + title_original: originalTitle, + year, + is_serial: 2, + category: '5000' + }); + + return this.filterByContentType(results, 'serial'); + } + + /** + * Поиск аниме + * @param {string} title - название на русском + * @param {string} originalTitle - оригинальное название + * @param {number} year - год выпуска + * @returns {Promise} + */ + async searchAnime(title, originalTitle, year) { + const results = await this.searchTorrents({ + title, + title_original: originalTitle, + year, + is_serial: 5, + category: '5070' + }); + + return this.filterByContentType(results, 'anime'); + } + + /** + * Поиск по общему запросу с фильтрацией качества + * @param {string} query - поисковый запрос + * @param {string} type - тип контента (movie/serial/anime) + * @param {number} year - год выпуска + * @returns {Promise} + */ + async searchByQuery(query, type = 'movie', year = null) { + const params = { query }; + + if (year) params.year = year; + + switch (type) { + case 'movie': + params.is_serial = 1; + params.category = '2000'; + break; + case 'serial': + params.is_serial = 2; + params.category = '5000'; + break; + case 'anime': + params.is_serial = 5; + params.category = '5070'; + break; + } + + const results = await this.searchTorrents(params); + return this.filterByContentType(results, type); + } + + /** + * Получение информации о фильме по IMDB ID через Alloha API + * @param {string} imdbId - IMDB ID + * @returns {Promise} + */ + async getMovieInfoByImdb(imdbId) { + try { + const response = await axios.get( + `https://api.alloha.tv/?token=04941a9a3ca3ac16e2b4327347bbc1&imdb=${imdbId}`, + { timeout: 10000 } + ); + + const data = response.data?.data; + return data ? { + name: data.name, + original_name: data.original_name + } : null; + } catch (error) { + console.error('Ошибка получения информации по IMDB:', error.message); + return null; + } + } + + /** + * Получение информации по Kinopoisk ID + * @param {string} kpId - Kinopoisk ID + * @returns {Promise} + */ + async getMovieInfoByKinopoisk(kpId) { + try { + const response = await axios.get( + `https://api.alloha.tv/?token=04941a9a3ca3ac16e2b4327347bbc1&kp=${kpId}`, + { timeout: 10000 } + ); + + const data = response.data?.data; + return data ? { + name: data.name, + original_name: data.original_name + } : null; + } catch (error) { + console.error('Ошибка получения информации по Kinopoisk ID:', error.message); + return null; + } + } + + /** + * Поиск по IMDB ID + * @param {string} imdbId - IMDB ID (например, 'tt1234567') + * @param {string} type - 'movie', 'serial' или 'anime' + * @returns {Promise} + */ + async searchByImdb(imdbId, type = 'movie') { + if (!imdbId || !imdbId.match(/^tt\d+$/)) { + throw new Error('Неверный формат IMDB ID. Должен быть в формате tt1234567'); + } + + console.log(`RedAPI search by IMDB ID: ${imdbId}`); + + // Сначала получаем информацию о фильме + const movieInfo = await this.getMovieInfoByImdb(imdbId); + + const params = { imdb: imdbId }; + + // Устанавливаем категорию и тип контента + switch (type) { + case 'movie': + params.is_serial = 1; + params.category = '2000'; + break; + case 'serial': + params.is_serial = 2; + params.category = '5000'; + break; + case 'anime': + params.is_serial = 5; + params.category = '5070'; + break; + default: + params.is_serial = 1; + params.category = '2000'; + } + + // Если получили информацию о фильме, добавляем названия + if (movieInfo) { + params.title = movieInfo.name; + params.title_original = movieInfo.original_name; + } + + const results = await this.searchTorrents(params); + return this.filterByContentType(results, type); + } + + /** + * Поиск по Kinopoisk ID + * @param {string} kpId - Kinopoisk ID + * @param {string} type - 'movie', 'serial' или 'anime' + * @returns {Promise} + */ + async searchByKinopoisk(kpId, type = 'movie') { + if (!kpId || !kpId.toString().match(/^\d+$/)) { + throw new Error('Неверный формат Kinopoisk ID'); + } + + console.log(`RedAPI search by Kinopoisk ID: ${kpId}`); + + const movieInfo = await this.getMovieInfoByKinopoisk(kpId); + + const params = { query: `kp${kpId}` }; + + switch (type) { + case 'movie': + params.is_serial = 1; + params.category = '2000'; + break; + case 'serial': + params.is_serial = 2; + params.category = '5000'; + break; + case 'anime': + params.is_serial = 5; + params.category = '5070'; + break; + } + + if (movieInfo) { + params.title = movieInfo.name; + params.title_original = movieInfo.original_name; + } + + const results = await this.searchTorrents(params); + return this.filterByContentType(results, type); + } + + /** + * Расширенная фильтрация по качеству + * @param {Array} results - результаты поиска + * @param {Object} qualityFilter - объект с параметрами фильтрации качества + * @returns {Array} + */ + filterByQuality(results, qualityFilter = {}) { + if (!qualityFilter || Object.keys(qualityFilter).length === 0) { + return results; + } + + const { + qualities = [], // ['1080p', '720p', '4K', '2160p'] + minQuality = null, // минимальное качество + maxQuality = null, // максимальное качество + excludeQualities = [], // исключить качества + hdr = null, // true/false для HDR + hevc = null // true/false для HEVC/H.265 + } = qualityFilter; + + // Порядок качества от низкого к высокому + const qualityOrder = ['360p', '480p', '720p', '1080p', '1440p', '2160p', '4K']; + + return results.filter(torrent => { + const title = torrent.title.toLowerCase(); + + // Определяем качество из названия + const detectedQuality = this.detectQuality(title); + + // Фильтрация по конкретным качествам + if (qualities.length > 0) { + const hasQuality = qualities.some(q => + title.includes(q.toLowerCase()) || + (q === '4K' && title.includes('2160p')) + ); + if (!hasQuality) return false; + } + + // Фильтрация по минимальному качеству + if (minQuality && detectedQuality) { + const minIndex = qualityOrder.indexOf(minQuality); + const currentIndex = qualityOrder.indexOf(detectedQuality); + if (currentIndex !== -1 && minIndex !== -1 && currentIndex < minIndex) { + return false; + } + } + + // Фильтрация по максимальному качеству + if (maxQuality && detectedQuality) { + const maxIndex = qualityOrder.indexOf(maxQuality); + const currentIndex = qualityOrder.indexOf(detectedQuality); + if (currentIndex !== -1 && maxIndex !== -1 && currentIndex > maxIndex) { + return false; + } + } + + // Исключение определенных качеств + if (excludeQualities.length > 0) { + const hasExcluded = excludeQualities.some(q => + title.includes(q.toLowerCase()) + ); + if (hasExcluded) return false; + } + + // Фильтрация по HDR + if (hdr !== null) { + const hasHDR = /hdr|dolby.vision|dv/i.test(title); + if (hdr && !hasHDR) return false; + if (!hdr && hasHDR) return false; + } + + // Фильтрация по HEVC + if (hevc !== null) { + const hasHEVC = /hevc|h\.265|x265/i.test(title); + if (hevc && !hasHEVC) return false; + if (!hevc && hasHEVC) return false; + } + + return true; + }); + } + + /** + * Определение качества из названия торрента + * @param {string} title - название торрента + * @returns {string|null} + */ + detectQuality(title) { + const qualityPatterns = [ + { pattern: /2160p|4k/i, quality: '2160p' }, + { pattern: /1440p/i, quality: '1440p' }, + { pattern: /1080p/i, quality: '1080p' }, + { pattern: /720p/i, quality: '720p' }, + { pattern: /480p/i, quality: '480p' }, + { pattern: /360p/i, quality: '360p' } + ]; + + for (const { pattern, quality } of qualityPatterns) { + if (pattern.test(title)) { + return quality; + } + } + + return null; + } + + /** + * Получение статистики по качеству + * @param {Array} results - результаты поиска + * @returns {Object} + */ + getQualityStats(results) { + const stats = {}; + + results.forEach(torrent => { + const quality = this.detectQuality(torrent.title.toLowerCase()); + if (quality) { + stats[quality] = (stats[quality] || 0) + 1; + } + }); + + return stats; + } + + /** + * Группировка результатов по качеству + * @param {Array} results - результаты поиска + * @returns {Object} - объект с группами качества + */ + groupByQuality(results) { + const groups = { + '4K': [], + '2160p': [], + '1440p': [], + '1080p': [], + '720p': [], + '480p': [], + '360p': [], + 'unknown': [] + }; + + results.forEach(torrent => { + const quality = this.detectQuality(torrent.title.toLowerCase()); + + if (quality) { + // Объединяем 4K и 2160p в одну группу + if (quality === '2160p') { + groups['4K'].push(torrent); + } else { + groups[quality].push(torrent); + } + } else { + groups['unknown'].push(torrent); + } + }); + + // Удаляем пустые группы и сортируем по качеству (от высокого к низкому) + const sortedGroups = {}; + const qualityOrder = ['4K', '1440p', '1080p', '720p', '480p', '360p', 'unknown']; + + qualityOrder.forEach(quality => { + if (groups[quality].length > 0) { + // Сортируем торренты внутри группы по сидам + groups[quality].sort((a, b) => (b.seeders || 0) - (a.seeders || 0)); + sortedGroups[quality] = groups[quality]; + } + }); + + return sortedGroups; + } + + /** + * Расширенный поиск с поддержкой сезонов + * @param {Object} searchParams - параметры поиска + * @param {Object} qualityFilter - фильтр качества + * @returns {Promise} + */ + async searchWithQualityFilter(searchParams, qualityFilter = {}) { + const results = await this.searchTorrents(searchParams); + + // Применяем фильтрацию по типу контента + let filteredResults = results; + if (searchParams.contentType) { + filteredResults = this.filterByContentType(results, searchParams.contentType); + } + + // Применяем фильтрацию по сезону (дополнительная на клиенте) + if (searchParams.season && !searchParams.seasonFromAPI) { + filteredResults = this.filterBySeason(filteredResults, searchParams.season); + } + + // Применяем фильтрацию по качеству + filteredResults = this.filterByQuality(filteredResults, qualityFilter); + + // Сортируем результаты + if (qualityFilter.sortBy) { + filteredResults = this.sortTorrents(filteredResults, qualityFilter.sortBy, qualityFilter.sortOrder); + } + + return filteredResults; + } + + /** + * Сортировка результатов + * @param {Array} results - результаты поиска + * @param {string} sortBy - поле для сортировки (seeders/size/date) + * @param {string} order - порядок сортировки (asc/desc) + * @returns {Array} + */ + sortTorrents(results, sortBy = 'seeders', order = 'desc') { + return results.sort((a, b) => { + let valueA, valueB; + + switch (sortBy) { + case 'seeders': + valueA = a.seeders || 0; + valueB = b.seeders || 0; + break; + case 'size': + valueA = a.size || 0; + valueB = b.size || 0; + break; + case 'date': + valueA = new Date(a.publishDate || 0); + valueB = new Date(b.publishDate || 0); + break; + default: + return 0; + } + + if (order === 'asc') { + return valueA - valueB; + } else { + return valueB - valueA; + } + }); + } + + /** + * Поиск сериалов с поддержкой выбора сезона + * @param {string} title - название на русском + * @param {string} originalTitle - оригинальное название + * @param {number} year - год выпуска + * @param {number} season - номер сезона (опционально) + * @param {Object} qualityFilter - фильтр качества + * @returns {Promise} + */ + async searchSeries(title, originalTitle, year, season = null, qualityFilter = {}) { + const params = { + title, + title_original: originalTitle, + year, + is_serial: 2, + category: '5000', + contentType: 'serial' + }; + + if (season) { + params.season = season; + } + + return this.searchWithQualityFilter(params, qualityFilter); + } + + /** + * Получение доступных сезонов для сериала + * @param {string} title - название сериала + * @param {string} originalTitle - оригинальное название + * @param {number} year - год выпуска + * @returns {Promise} - массив номеров сезонов + */ + async getAvailableSeasons(title, originalTitle, year) { + const results = await this.searchSeries(title, originalTitle, year); + const seasons = new Set(); + + results.forEach(torrent => { + // Extract from the dedicated field + if (torrent.seasons && Array.isArray(torrent.seasons)) { + torrent.seasons.forEach(s => seasons.add(parseInt(s))); + } + + // Extract from title + const title = torrent.title; + const seasonRegex = /(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон/gi; + for (const match of title.matchAll(seasonRegex)) { + const seasonNumber = parseInt(match[1] || match[2]); + if (!isNaN(seasonNumber)) { + seasons.add(seasonNumber); + } + } + }); + + return Array.from(seasons).sort((a, b) => a - b); + } + + /** + * Фильтрация результатов по сезону на клиенте + * @param {Array} results - результаты поиска + * @param {number} season - номер сезона + * @returns {Array} + */ + filterBySeason(results, season) { + if (!season) return results; + + return results.filter(torrent => { + // Проверяем поле seasons + if (torrent.seasons && Array.isArray(torrent.seasons)) { + return torrent.seasons.includes(season); + } + + // Проверяем название торрента + const title = torrent.title.toLowerCase(); + const seasonPatterns = [ + new RegExp(`сезон[\\s:]*${season}`, 'i'), + new RegExp(`season[\\s:]*${season}`, 'i'), + new RegExp(`s${season}(?![0-9])`, 'i'), + new RegExp(`${season}[\\s]*сезон`, 'i') + ]; + + return seasonPatterns.some(pattern => pattern.test(title)); + }); + } + + /** + * Поиск конкретного сезона сериала + * @param {string} title - название сериала + * @param {string} originalTitle - оригинальное название + * @param {number} year - год выпуска + * @param {number} season - номер сезона + * @param {Object} qualityFilter - фильтр качества + * @returns {Promise} + */ + async searchSeriesSeason(title, originalTitle, year, season, qualityFilter = {}) { + // Сначала пробуем поиск с параметром season + let results = await this.searchSeries(title, originalTitle, year, season, qualityFilter); + + // Если результатов мало, делаем общий поиск и фильтруем на клиенте + if (results.length < 5) { + const allResults = await this.searchSeries(title, originalTitle, year, null, qualityFilter); + const filteredResults = this.filterBySeason(allResults, season); + + // Объединяем результаты и убираем дубликаты + const combined = [...results, ...filteredResults]; + const unique = combined.filter((torrent, index, self) => + index === self.findIndex(t => t.magnet === torrent.magnet) + ); + + results = unique; + } + + return results; + } + + /** + * Группировка результатов по сезону + * @param {Array} results - результаты поиска + * @returns {Object} - объект с группами по сезонам + */ + groupBySeason(results) { + const grouped = {}; + + results.forEach(torrent => { + const seasons = new Set(); + + // Extract seasons from the dedicated field + if (torrent.seasons && Array.isArray(torrent.seasons)) { + torrent.seasons.forEach(s => seasons.add(parseInt(s))); + } + + // Extract from title as a fallback or supplement + const title = torrent.title; + const seasonRegex = /(?:s|сезон)[\s:]*(\d+)|(\d+)\s*сезон/gi; + for (const match of title.matchAll(seasonRegex)) { + const seasonNumber = parseInt(match[1] || match[2]); + if (!isNaN(seasonNumber)) { + seasons.add(seasonNumber); + } + } + + const seasonsArray = Array.from(seasons); + + // If no season is found, group as 'unknown' + if (seasonsArray.length === 0) { + seasonsArray.push('unknown'); + } + + // Add torrent to all relevant season groups + seasonsArray.forEach(season => { + const seasonKey = season === 'unknown' ? 'Неизвестно' : `Сезон ${season}`; + if (!grouped[seasonKey]) { + grouped[seasonKey] = []; + } + // Ensure torrent is not added to the same group twice + if (!grouped[seasonKey].find(t => t.magnet === torrent.magnet)) { + grouped[seasonKey].push(torrent); + } + }); + }); + + // Sort torrents within each group by seeders + Object.keys(grouped).forEach(season => { + grouped[season].sort((a, b) => (b.seeders || 0) - (a.seeders || 0)); + }); + + return grouped; + } +} + +module.exports = RedApiClient; \ No newline at end of file diff --git a/src/services/torrent.service.js b/src/services/torrent.service.js new file mode 100644 index 0000000..cf390ee --- /dev/null +++ b/src/services/torrent.service.js @@ -0,0 +1,129 @@ +const RedApiClient = require('./redapi.service'); + +class TorrentService { + constructor() { + this.redApiClient = new RedApiClient(); + } + + /** + * Получить название фильма/сериала по IMDB ID + * @param {object} tmdbClient - TMDB клиент + * @param {string} imdbId - IMDB ID (например, 'tt1234567') + * @param {string} type - 'movie' или 'tv' + * @returns {Promise<{originalTitle: string, russianTitle: string, year: string}|null>} + */ + async getTitleByImdbId(tmdbClient, imdbId, type) { + try { + const tmdbType = (type === 'serial' || type === 'tv') ? 'tv' : 'movie'; + + const response = await tmdbClient.makeRequest('GET', `/find/${imdbId}`, { + params: { + external_source: 'imdb_id', + language: 'ru-RU' + } + }); + + const data = response.data; + const results = tmdbType === 'movie' ? data.movie_results : data.tv_results; + + if (results && results.length > 0) { + const item = results[0]; + const tmdbId = item.id; + + // Получаем детали для оригинального названия + const detailsResponse = await tmdbClient.makeRequest('GET', + tmdbType === 'movie' ? `/movie/${tmdbId}` : `/tv/${tmdbId}`, + { + params: { + language: 'en-US' // Получаем оригинальное название + } + } + ); + + const details = detailsResponse.data; + const originalTitle = tmdbType === 'movie' + ? details.original_title || details.title + : details.original_name || details.name; + + const russianTitle = tmdbType === 'movie' + ? item.title || item.original_title + : item.name || item.original_name; + + return { + originalTitle: originalTitle, + russianTitle: russianTitle, + year: (item.release_date || item.first_air_date)?.split('-')[0] + }; + } + return null; + } catch (error) { + console.error(`Error getting title by IMDB ID: ${error.message}`); + return null; + } + } + + /** + * Поиск торрентов по IMDB ID через RedAPI с поддержкой сезонов + * @param {object} tmdbClient - TMDB клиент + * @param {string} imdbId - IMDB ID (tt1234567) + * @param {string} type - 'movie' или 'tv' + * @param {Object} options - дополнительные опции (например, season) + * @returns {Promise} + */ + async searchTorrentsByImdbId(tmdbClient, imdbId, type = 'movie', options = {}) { + try { + console.log(`Starting RedAPI torrent search for IMDB ID: ${imdbId}, type: ${type}, season: ${options.season || 'all'}`); + + const movieInfo = await this.getTitleByImdbId(tmdbClient, imdbId, type); + if (!movieInfo) { + console.log('No movie info found for IMDB ID:', imdbId); + return []; + } + + console.log('Movie info found:', movieInfo); + + let results = []; + if (type === 'movie') { + results = await this.redApiClient.searchMovies( + movieInfo.russianTitle, + movieInfo.originalTitle, + movieInfo.year + ); + } else { + // Для сериалов используем метод с поддержкой сезонов + if (options.season) { + results = await this.redApiClient.searchSeriesSeason( + movieInfo.russianTitle, + movieInfo.originalTitle, + movieInfo.year, + options.season + ); + } else { + results = await this.redApiClient.searchSeries( + movieInfo.russianTitle, + movieInfo.originalTitle, + movieInfo.year + ); + } + } + + if (results.length === 0) { + console.log('No results found by titles, trying query search...'); + const query = movieInfo.originalTitle || movieInfo.russianTitle; + let searchQuery = `${query} ${movieInfo.year}`; + if (options.season && type === 'tv') { + searchQuery += ` season ${options.season}`; + } + results = await this.redApiClient.searchByQuery(searchQuery, type, movieInfo.year); + } + + console.log(`Found ${results.length} torrent results via RedAPI`); + return results.slice(0, 20); + } catch (e) { + console.error('Error searching torrents by IMDB ID:', e.message); + return []; + } + } +} + +module.exports = TorrentService;