2025-07-02 14:16:25 +02:00
import { config } from './config.js' ;
import { state } from './state.js' ;
import { fetchWithTimeout } from './utils.js' ;
import { getFromDB } from './db.js' ;
import { _ } from './utils.js' ;
2025-07-25 23:57:03 +02:00
export async function fetchTMDB ( endpoint , params = { } , signal ) {
2025-07-28 13:58:30 +02:00
const region = state . settings . watchRegion || 'US' ;
2025-08-02 09:57:03 +02:00
const lang = state . settings . language || 'en' ;
2025-07-02 14:16:25 +02:00
2025-07-25 23:57:03 +02:00
const [ path , existingQuery ] = endpoint . split ( '?' ) ;
const finalParams = new URLSearchParams ( existingQuery ) ;
finalParams . set ( 'api_key' , state . settings . apiKey ) ;
2025-08-02 09:57:03 +02:00
finalParams . set ( 'language' , lang ) ;
2025-07-28 13:58:30 +02:00
finalParams . set ( 'watch_region' , region ) ;
2025-07-25 23:57:03 +02:00
2025-08-02 09:57:03 +02:00
// Añadir filtros de puntuación y duración
if ( params . minScore ) finalParams . set ( 'vote_average.gte' , params . minScore ) ;
if ( params . maxScore ) finalParams . set ( 'vote_average.lte' , params . maxScore ) ;
if ( params . minDuration ) finalParams . set ( 'with_runtime.gte' , params . minDuration ) ;
if ( params . maxDuration ) finalParams . set ( 'with_runtime.lte' , params . maxDuration ) ;
2025-07-25 23:57:03 +02:00
for ( const [ key , value ] of Object . entries ( params ) ) {
if ( value ) {
finalParams . set ( key , value ) ;
}
}
const url = ` https://api.themoviedb.org/3/ ${ path } ? ${ finalParams . toString ( ) } ` ;
2025-07-12 12:56:04 +02:00
2025-07-02 14:16:25 +02:00
const response = await fetch ( url , { signal } ) ;
if ( ! response . ok ) {
const errorData = await response . json ( ) . catch ( ( ) => ( { status _message : "Unknown error" } ) ) ;
throw new Error ( ` HTTP error! status: ${ response . status } - ${ errorData . status _message } ` ) ;
}
return response . json ( ) ;
}
2025-07-12 12:56:04 +02:00
export async function fetchPlexSessions ( server ) {
const { protocolo , ip , puerto , token } = server ;
const url = ` ${ protocolo } :// ${ ip } : ${ puerto } /status/sessions?X-Plex-Token= ${ token } ` ;
const response = await fetchWithTimeout ( url , { headers : { 'Accept' : 'application/json' } } , 8000 ) ;
if ( ! response . ok ) {
throw new Error ( ` Error ${ response . status } ` ) ;
}
const data = await response . json ( ) ;
return data . MediaContainer . Metadata || [ ] ;
}
2025-07-02 14:16:25 +02:00
export async function getMusicUrlsFromPlex ( token , protocolo , ip , puerto , artistaId ) {
const url = ` ${ protocolo } :// ${ ip } : ${ puerto } /library/metadata/ ${ artistaId } /allLeaves?X-Plex-Token= ${ token } ` ;
try {
const response = await fetchWithTimeout ( url , { } , 15000 ) ;
if ( ! response . ok ) throw new Error ( ` Failed to fetch tracks: ${ response . status } ` ) ;
2025-07-12 12:56:04 +02:00
2025-07-02 14:16:25 +02:00
const data = await response . text ( ) ;
const parser = new DOMParser ( ) ;
const xmlDoc = parser . parseFromString ( data , "text/xml" ) ;
if ( xmlDoc . querySelector ( 'parsererror' ) ) throw new Error ( "Failed to parse track XML" ) ;
const tracks = Array . from ( xmlDoc . querySelectorAll ( "Track" ) ) . map ( track => {
const part = track . querySelector ( "Part" ) ;
if ( ! part || ! part . getAttribute ( "key" ) ) return null ;
const fileKey = part . getAttribute ( "key" ) ;
const fileUrl = ` ${ protocolo } :// ${ ip } : ${ puerto } ${ fileKey } ?X-Plex-Token= ${ token } ` ;
2025-07-12 12:56:04 +02:00
2025-07-02 14:16:25 +02:00
const thumb = track . getAttribute ( "thumb" ) ;
const parentThumb = track . getAttribute ( "parentThumb" ) ;
const grandparentThumb = track . getAttribute ( "grandparentThumb" ) ;
2025-07-12 12:56:04 +02:00
2025-07-02 14:16:25 +02:00
let coverUrl = 'img/no-poster.png' ;
if ( thumb ) {
coverUrl = ` ${ protocolo } :// ${ ip } : ${ puerto } ${ thumb } ?X-Plex-Token= ${ token } ` ;
} else if ( parentThumb ) {
coverUrl = ` ${ protocolo } :// ${ ip } : ${ puerto } ${ parentThumb } ?X-Plex-Token= ${ token } ` ;
} else if ( grandparentThumb ) {
coverUrl = ` ${ protocolo } :// ${ ip } : ${ puerto } ${ grandparentThumb } ?X-Plex-Token= ${ token } ` ;
}
return {
url : fileUrl ,
titulo : track . getAttribute ( "title" ) || 'Pista desconocida' ,
album : track . getAttribute ( "parentTitle" ) || 'Álbum desconocido' ,
artista : track . getAttribute ( "grandparentTitle" ) || 'Artista desconocido' ,
cover : coverUrl ,
extension : part . getAttribute ( "container" ) || "mp3" ,
id : track . getAttribute ( "ratingKey" ) ,
artistId : track . getAttribute ( "grandparentRatingKey" ) || artistaId ,
year : track . getAttribute ( "parentYear" ) || track . getAttribute ( "year" ) ,
genre : Array . from ( track . querySelectorAll ( "Genre" ) ) . map ( g => g . getAttribute ( 'tag' ) ) . join ( ', ' ) || '' ,
index : parseInt ( track . getAttribute ( "index" ) || 0 , 10 ) ,
2025-07-28 00:18:53 +02:00
albumIndex : parseInt ( track . getAttribute ( "parentIndex" ) || 0 , 10 ) ,
trackIndex : parseInt ( track . getAttribute ( "index" ) || 0 , 10 )
2025-07-02 14:16:25 +02:00
} ;
} ) . filter ( track => track !== null ) ;
2025-07-12 12:56:04 +02:00
2025-07-02 14:16:25 +02:00
tracks . sort ( ( a , b ) => {
if ( a . albumIndex !== b . albumIndex ) {
return a . albumIndex - b . albumIndex ;
}
return a . index - b . index ;
} ) ;
return tracks ;
} catch ( error ) {
2025-07-04 09:36:40 +02:00
console . error ( "Error in getMusicUrlsFromPlex:" , error ) ;
2025-07-02 14:16:25 +02:00
throw error ;
}
}
2025-07-28 00:18:53 +02:00
export async function getMusicUrlsFromJellyfin ( serverUrl , userId , token , artistId ) {
try {
const albumsUrl = ` ${ serverUrl } /Users/ ${ userId } /Items?ParentId= ${ artistId } &IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ImageTags ` ;
const albumsResponse = await fetch ( albumsUrl , { headers : { 'X-Emby-Token' : token } } ) ;
if ( ! albumsResponse . ok ) throw new Error ( ` Failed to fetch albums: ${ albumsResponse . status } ` ) ;
const albumsData = await albumsResponse . json ( ) ;
let allTracks = [ ] ;
for ( const album of albumsData . Items ) {
const songsUrl = ` ${ serverUrl } /Users/ ${ userId } /Items?ParentId= ${ album . Id } &IncludeItemTypes=Audio&Recursive=true&Fields=MediaSources,ImageTags ` ;
const songsResponse = await fetch ( songsUrl , { headers : { 'X-Emby-Token' : token } } ) ;
if ( ! songsResponse . ok ) continue ;
const songsData = await songsResponse . json ( ) ;
const albumTracks = songsData . Items . map ( track => {
const source = track . MediaSources ? . [ 0 ] ;
if ( ! source ) return null ;
2025-07-28 18:50:25 +02:00
const streamUrl = ` ${ serverUrl } /Audio/ ${ track . Id } /stream?api_key= ${ token } &static=true ` ;
2025-07-28 00:18:53 +02:00
const coverUrl = album . ImageTags ? . Primary ? ` ${ serverUrl } /Items/ ${ album . Id } /Images/Primary?tag= ${ album . ImageTags . Primary } ` : 'img/no-poster.png' ;
return {
url : streamUrl ,
titulo : track . Name || 'Pista desconocida' ,
album : album . Name || 'Álbum desconocido' ,
artista : track . AlbumArtist || 'Artista desconocido' ,
cover : coverUrl ,
extension : source . Container || "mp3" ,
id : track . Id ,
artistId : artistId ,
year : album . ProductionYear ,
genre : track . Genres ? . join ( ', ' ) || '' ,
index : track . IndexNumber || 0 ,
albumIndex : album . IndexNumber || 0 ,
trackIndex : track . IndexNumber || 0
} ;
} ) . filter ( track => track !== null ) ;
allTracks . push ( ... albumTracks ) ;
}
allTracks . sort ( ( a , b ) => {
if ( a . albumIndex !== b . albumIndex ) {
return a . albumIndex - b . albumIndex ;
}
return a . index - b . index ;
} ) ;
return allTracks ;
} catch ( error ) {
console . error ( "Error in getMusicUrlsFromJellyfin:" , error ) ;
throw error ;
}
}
2025-08-02 09:57:03 +02:00
export async function fetchAllStreamsFromPlex ( busqueda , tipoContenido , year = null ) {
2025-07-02 14:16:25 +02:00
if ( ! busqueda || ! tipoContenido ) return { success : false , streams : [ ] , message : _ ( 'invalidStreamInfo' ) } ;
if ( ! state . db ) return { success : false , streams : [ ] , message : _ ( 'dbUnavailableForStreams' ) } ;
2025-07-04 09:36:40 +02:00
const plexSearchType = tipoContenido === 'movie' ? '1' : '2' ;
2025-07-02 14:16:25 +02:00
const servers = await getFromDB ( 'conexiones_locales' ) ;
if ( ! servers || servers . length === 0 ) return { success : false , streams : [ ] , message : _ ( 'noPlexServersForStreams' ) } ;
2025-07-04 09:36:40 +02:00
const searchTasks = servers . map ( async ( server ) => {
const { ip , puerto , token , protocolo = 'http' , nombre : serverName = 'Servidor Desconocido' } = server ;
if ( ! ip || ! puerto || ! token ) return [ ] ;
const searchUrl = ` ${ protocolo } :// ${ ip } : ${ puerto } /search?type= ${ plexSearchType } &query= ${ encodeURIComponent ( busqueda ) } &X-Plex-Token= ${ token } ` ;
let serverStreams = [ ] ;
try {
const response = await fetchWithTimeout ( searchUrl , { headers : { 'Accept' : 'application/xml' } } ) ;
if ( ! response . ok ) return [ ] ;
const data = await response . text ( ) ;
const parser = new DOMParser ( ) ;
const xml = parser . parseFromString ( data , "text/xml" ) ;
if ( xml . querySelector ( 'parsererror' ) ) return [ ] ;
if ( tipoContenido === 'movie' ) {
const videos = Array . from ( xml . querySelectorAll ( "Video" ) ) ;
2025-08-02 09:57:03 +02:00
let videosToProcess = [ ] ;
if ( year ) {
const exactMatch = videos . find ( v =>
v . getAttribute ( 'title' ) ? . toLowerCase ( ) === busqueda . toLowerCase ( ) &&
v . getAttribute ( 'year' ) == year
) ;
if ( exactMatch ) {
videosToProcess = [ exactMatch ] ;
}
} else {
const exactMatch = videos . find ( v => v . getAttribute ( 'title' ) ? . toLowerCase ( ) === busqueda . toLowerCase ( ) ) ;
if ( exactMatch ) {
videosToProcess = [ exactMatch ] ;
} else {
videosToProcess = videos ;
}
2025-07-04 09:36:40 +02:00
}
videosToProcess . forEach ( video => {
const part = video . querySelector ( "Part" ) ;
if ( part && part . getAttribute ( "key" ) ) {
const movieTitle = video . getAttribute ( "title" ) || busqueda ;
const movieYear = video . getAttribute ( "year" ) ;
const streamUrl = ` ${ protocolo } :// ${ ip } : ${ puerto } ${ part . getAttribute ( "key" ) } ?X-Plex-Token= ${ token } ` ;
const extinfName = ` ${ movieTitle } ${ movieYear ? ` ( ${ movieYear } ) ` : '' } ` ;
const logoUrl = video . getAttribute ( "thumb" ) ? ` ${ protocolo } :// ${ ip } : ${ puerto } ${ video . getAttribute ( "thumb" ) } ?X-Plex-Token= ${ token } ` : '' ;
const groupTitle = extinfName . replace ( /"/g , "'" ) ;
serverStreams . push ( {
url : streamUrl ,
title : extinfName ,
extinf : ` #EXTINF:-1 tvg-name=" ${ extinfName . replace ( /"/g , "'" ) } " tvg-logo=" ${ logoUrl } " group-title=" ${ groupTitle } ", ${ extinfName } `
} ) ;
}
} ) ;
2025-08-02 09:57:03 +02:00
} else {
2025-07-04 09:36:40 +02:00
const directories = Array . from ( xml . querySelectorAll ( 'Directory[type="show"]' ) ) ;
2025-08-02 09:57:03 +02:00
let directoryToProcess ;
if ( year ) {
directoryToProcess = directories . find ( d =>
d . getAttribute ( 'title' ) ? . toLowerCase ( ) === busqueda . toLowerCase ( ) &&
d . getAttribute ( 'year' ) == year
) ;
}
if ( ! directoryToProcess ) {
directoryToProcess = directories . find ( d => d . getAttribute ( 'title' ) ? . toLowerCase ( ) === busqueda . toLowerCase ( ) ) ;
}
2025-07-04 09:36:40 +02:00
if ( ! directoryToProcess && directories . length > 0 ) {
2025-07-12 12:56:04 +02:00
directoryToProcess = directories [ 0 ] ;
2025-07-04 09:36:40 +02:00
}
2025-07-12 12:56:04 +02:00
2025-07-04 09:36:40 +02:00
if ( directoryToProcess && directoryToProcess . getAttribute ( "ratingKey" ) ) {
const serieKey = directoryToProcess . getAttribute ( "ratingKey" ) ;
const serieTitulo = directoryToProcess . getAttribute ( "title" ) || busqueda ;
const serieYear = directoryToProcess . getAttribute ( "year" ) ;
const leavesUrl = ` ${ protocolo } :// ${ ip } : ${ puerto } /library/metadata/ ${ serieKey } /allLeaves?X-Plex-Token= ${ token } ` ;
2025-07-12 12:56:04 +02:00
2025-07-04 09:36:40 +02:00
const leavesResponse = await fetchWithTimeout ( leavesUrl , { headers : { 'Accept' : 'application/xml' } } ) ;
if ( leavesResponse . ok ) {
const leavesData = await leavesResponse . text ( ) ;
const leavesXml = parser . parseFromString ( leavesData , "text/xml" ) ;
if ( ! leavesXml . querySelector ( 'parsererror' ) ) {
const episodes = Array . from ( leavesXml . querySelectorAll ( "Video" ) ) ;
2025-07-12 12:56:04 +02:00
episodes . sort ( ( a , b ) => {
2025-07-04 09:36:40 +02:00
const seasonA = parseInt ( a . getAttribute ( "parentIndex" ) || 0 , 10 ) ;
const seasonB = parseInt ( b . getAttribute ( "parentIndex" ) || 0 , 10 ) ;
2025-07-12 12:56:04 +02:00
if ( seasonA !== seasonB ) return seasonA - seasonB ;
2025-07-04 09:36:40 +02:00
const episodeA = parseInt ( a . getAttribute ( "index" ) || 0 , 10 ) ;
const episodeB = parseInt ( b . getAttribute ( "index" ) || 0 , 10 ) ;
return episodeA - episodeB ;
} ) ;
episodes . forEach ( episode => {
const part = episode . querySelector ( "Part" ) ;
if ( part && part . getAttribute ( "key" ) ) {
const seasonNum = episode . getAttribute ( "parentIndex" ) || 'S' ;
const episodeNum = episode . getAttribute ( "index" ) || 'E' ;
const episodeTitle = episode . getAttribute ( "title" ) || 'Episodio' ;
const streamUrl = ` ${ protocolo } :// ${ ip } : ${ puerto } ${ part . getAttribute ( "key" ) } ?X-Plex-Token= ${ token } ` ;
const groupTitle = ` ${ serieTitulo } ${ serieYear ? ` ( ${ serieYear } ) ` : '' } - Temporada ${ seasonNum } ` . replace ( /"/g , "'" ) ;
const extinfName = ` ${ serieTitulo } T ${ seasonNum } E ${ episodeNum } ${ episodeTitle } ` ;
const logoUrl = episode . getAttribute ( "grandparentThumb" ) || episode . getAttribute ( "parentThumb" ) || episode . getAttribute ( "thumb" ) ;
const fullLogoUrl = logoUrl ? ` ${ protocolo } :// ${ ip } : ${ puerto } ${ logoUrl } ?X-Plex-Token= ${ token } ` : '' ;
serverStreams . push ( {
url : streamUrl ,
title : extinfName ,
extinf : ` #EXTINF:-1 tvg-name=" ${ extinfName . replace ( /"/g , "'" ) } " tvg-logo=" ${ fullLogoUrl } " group-title=" ${ groupTitle } ", ${ extinfName } `
} ) ;
}
} ) ;
}
}
}
}
return serverStreams ;
} catch ( error ) {
console . warn ( ` Error buscando streams en ${ serverName } : ` , error . message ) ;
return [ ] ;
}
} ) ;
2025-07-02 14:16:25 +02:00
const results = await Promise . allSettled ( searchTasks ) ;
const allFoundStreams = results
. filter ( r => r . status === 'fulfilled' && Array . isArray ( r . value ) )
. flatMap ( r => r . value ) ;
const uniqueStreams = [ ] ;
const seenUrls = new Set ( ) ;
for ( const stream of allFoundStreams ) {
if ( ! seenUrls . has ( stream . url ) ) {
uniqueStreams . push ( stream ) ;
seenUrls . add ( stream . url ) ;
}
}
if ( tipoContenido === 'movie' ) {
2025-07-12 12:56:04 +02:00
uniqueStreams . sort ( ( a , b ) => ( a . title || '' ) . localeCompare ( b . title || '' ) ) ;
2025-07-02 14:16:25 +02:00
}
if ( uniqueStreams . length > 0 ) {
return { success : true , streams : uniqueStreams } ;
} else {
return { success : false , streams : [ ] , message : _ ( 'notFoundOnServers' , busqueda ) } ;
}
2025-07-11 12:10:50 +02:00
}
export async function fetchAllStreamsFromJellyfin ( busqueda , tipoContenido ) {
if ( ! busqueda || ! tipoContenido ) return { success : false , streams : [ ] , message : _ ( 'invalidStreamInfo' ) } ;
2025-07-12 12:56:04 +02:00
2025-07-11 12:10:50 +02:00
const { url , userId , apiKey } = state . jellyfinSettings ;
if ( ! url || ! userId || ! apiKey ) return { success : false , streams : [ ] , message : _ ( 'noJellyfinCredentials' ) } ;
const jellyfinSearchType = tipoContenido === 'movie' ? 'Movie' : 'Series' ;
const searchUrl = ` ${ url } /Users/ ${ userId } /Items?searchTerm= ${ encodeURIComponent ( busqueda ) } &IncludeItemTypes= ${ jellyfinSearchType } &Recursive=true ` ;
2025-07-12 12:56:04 +02:00
2025-07-11 12:10:50 +02:00
try {
const response = await fetch ( searchUrl , { headers : { 'X-Emby-Token' : apiKey } } ) ;
if ( ! response . ok ) throw new Error ( ` Error buscando en Jellyfin: ${ response . status } ` ) ;
const searchData = await response . json ( ) ;
2025-07-12 12:56:04 +02:00
2025-07-11 12:10:50 +02:00
if ( ! searchData . Items || searchData . Items . length === 0 ) {
return { success : false , streams : [ ] , message : _ ( 'notFoundOnJellyfin' , busqueda ) } ;
}
2025-07-12 12:56:04 +02:00
2025-07-11 12:10:50 +02:00
const item = searchData . Items . find ( i => i . Name . toLowerCase ( ) === busqueda . toLowerCase ( ) ) || searchData . Items [ 0 ] ;
const itemId = item . Id ;
const itemName = item . Name ;
const itemYear = item . ProductionYear ;
const posterTag = item . ImageTags ? . Primary ;
const posterUrl = posterTag ? ` ${ url } /Items/ ${ itemId } /Images/Primary?tag= ${ posterTag } ` : '' ;
let streams = [ ] ;
if ( item . Type === 'Movie' ) {
const streamUrl = ` ${ url } /Videos/ ${ itemId } /stream?api_key= ${ apiKey } ` ;
const extinfName = ` ${ itemName } ${ itemYear ? ` ( ${ itemYear } ) ` : '' } ` ;
const groupTitle = extinfName . replace ( /"/g , "'" ) ;
streams . push ( {
url : streamUrl ,
title : extinfName ,
extinf : ` #EXTINF:-1 tvg-name=" ${ extinfName . replace ( /"/g , "'" ) } " tvg-logo=" ${ posterUrl } " group-title=" ${ groupTitle } ", ${ extinfName } `
} ) ;
} else if ( item . Type === 'Series' ) {
const episodesUrl = ` ${ url } /Shows/ ${ itemId } /Episodes?userId= ${ userId } ` ;
const episodesResponse = await fetch ( episodesUrl , { headers : { 'X-Emby-Token' : apiKey } } ) ;
if ( ! episodesResponse . ok ) throw new Error ( ` Error obteniendo episodios: ${ episodesResponse . status } ` ) ;
const episodesData = await episodesResponse . json ( ) ;
2025-07-12 12:56:04 +02:00
const sortedEpisodes = episodesData . Items . sort ( ( a , b ) => {
2025-07-11 12:10:50 +02:00
if ( a . ParentIndexNumber !== b . ParentIndexNumber ) return ( a . ParentIndexNumber || 0 ) - ( b . ParentIndexNumber || 0 ) ;
return ( a . IndexNumber || 0 ) - ( b . IndexNumber || 0 ) ;
} ) ;
sortedEpisodes . forEach ( ep => {
const streamUrl = ` ${ url } /Videos/ ${ ep . Id } /stream?api_key= ${ apiKey } ` ;
const seasonNum = ep . ParentIndexNumber || 'S' ;
const episodeNum = ep . IndexNumber || 'E' ;
const episodeTitle = ep . Name || 'Episodio' ;
const groupTitle = ` ${ itemName } - Temporada ${ seasonNum } ` . replace ( /"/g , "'" ) ;
const extinfName = ` ${ itemName } T ${ seasonNum } E ${ episodeNum } ${ episodeTitle } ` ;
2025-07-12 12:56:04 +02:00
2025-07-11 12:10:50 +02:00
streams . push ( {
url : streamUrl ,
title : extinfName ,
extinf : ` #EXTINF:-1 tvg-name=" ${ extinfName . replace ( /"/g , "'" ) } " tvg-logo=" ${ posterUrl } " group-title=" ${ groupTitle } ", ${ extinfName } `
} ) ;
} ) ;
}
return { success : true , streams } ;
} catch ( error ) {
console . error ( "Error fetching streams from Jellyfin:" , error ) ;
return { success : false , streams : [ ] , message : error . message } ;
}
}
2025-08-02 09:57:03 +02:00
export async function fetchAllAvailableStreams ( title , type , year = null ) {
const plexPromise = fetchAllStreamsFromPlex ( title , type , year ) ;
const jellyfinPromise = fetchAllStreamsFromJellyfin ( title , type ) ; // Jellyfin no usa 'year' en su signature, lo he quitado para que no cause error si se le pasa.
2025-07-11 12:10:50 +02:00
const results = await Promise . allSettled ( [ plexPromise , jellyfinPromise ] ) ;
let allStreams = [ ] ;
const errorMessages = [ ] ;
results . forEach ( ( result , index ) => {
const sourceName = index === 0 ? 'Plex' : 'Jellyfin' ;
if ( result . status === 'fulfilled' && result . value . success ) {
allStreams . push ( ... result . value . streams ) ;
} else if ( result . status === 'fulfilled' && ! result . value . success ) {
if ( result . value . message !== _ ( 'noPlexServersForStreams' ) && result . value . message !== _ ( 'noJellyfinCredentials' ) ) {
errorMessages . push ( ` ${ sourceName } : ${ result . value . message } ` ) ;
}
} else if ( result . status === 'rejected' ) {
errorMessages . push ( ` ${ sourceName } : ${ result . reason . message } ` ) ;
}
} ) ;
2025-07-12 12:56:04 +02:00
2025-07-11 12:10:50 +02:00
const uniqueStreamsMap = new Map ( allStreams . map ( stream => [ stream . url , stream ] ) ) ;
const uniqueStreams = Array . from ( uniqueStreamsMap . values ( ) ) ;
2025-07-12 12:56:04 +02:00
2025-07-11 12:10:50 +02:00
if ( uniqueStreams . length > 0 ) {
return { success : true , streams : uniqueStreams , message : ` Found ${ uniqueStreams . length } streams. ` } ;
} else {
return { success : false , streams : [ ] , message : errorMessages . join ( '; ' ) || _ ( 'notFoundOnAnyServer' , title ) } ;
}
2025-07-02 14:16:25 +02:00
}