made it use an Array if needed
Some checks failed
Build and Release IPA / Build IPA (push) Has been cancelled

This commit is contained in:
Francesco 2025-03-30 17:25:39 +02:00
parent ee81815afa
commit 6243d456ca
7 changed files with 650 additions and 492 deletions

View file

@ -0,0 +1,185 @@
//
// JSControllerDetails.swift
// Sulfur
//
// Created by Francesco on 30/03/25.
//
import JavaScriptCore
extension JSController {
func fetchDetails(url: String, completion: @escaping ([MediaItem], [EpisodeLink]) -> Void) {
guard let url = URL(string: url) else {
completion([], [])
return
}
URLSession.custom.dataTask(with: url) { [weak self] data, _, error in
guard let self = self else { return }
if let error = error {
Logger.shared.log("Network error: \(error)",type: "Error")
DispatchQueue.main.async { completion([], []) }
return
}
guard let data = data, let html = String(data: data, encoding: .utf8) else {
Logger.shared.log("Failed to decode HTML",type: "Error")
DispatchQueue.main.async { completion([], []) }
return
}
var resultItems: [MediaItem] = []
var episodeLinks: [EpisodeLink] = []
Logger.shared.log(html,type: "HTMLStrings")
if let parseFunction = self.context.objectForKeyedSubscript("extractDetails"),
let results = parseFunction.call(withArguments: [html]).toArray() as? [[String: String]] {
resultItems = results.map { item in
MediaItem(
description: item["description"] ?? "",
aliases: item["aliases"] ?? "",
airdate: item["airdate"] ?? ""
)
}
} else {
Logger.shared.log("Failed to parse results",type: "Error")
}
if let fetchEpisodesFunction = self.context.objectForKeyedSubscript("extractEpisodes"),
let episodesResult = fetchEpisodesFunction.call(withArguments: [html]).toArray() as? [[String: String]] {
for episodeData in episodesResult {
if let num = episodeData["number"], let link = episodeData["href"], let number = Int(num) {
episodeLinks.append(EpisodeLink(number: number, href: link))
}
}
}
DispatchQueue.main.async {
completion(resultItems, episodeLinks)
}
}.resume()
}
func fetchDetailsJS(url: String, completion: @escaping ([MediaItem], [EpisodeLink]) -> Void) {
guard let url = URL(string: url) else {
completion([], [])
return
}
if let exception = context.exception {
Logger.shared.log("JavaScript exception: \(exception)",type: "Error")
completion([], [])
return
}
guard let extractDetailsFunction = context.objectForKeyedSubscript("extractDetails") else {
Logger.shared.log("No JavaScript function extractDetails found",type: "Error")
completion([], [])
return
}
guard let extractEpisodesFunction = context.objectForKeyedSubscript("extractEpisodes") else {
Logger.shared.log("No JavaScript function extractEpisodes found",type: "Error")
completion([], [])
return
}
var resultItems: [MediaItem] = []
var episodeLinks: [EpisodeLink] = []
let dispatchGroup = DispatchGroup()
dispatchGroup.enter()
let promiseValueDetails = extractDetailsFunction.call(withArguments: [url.absoluteString])
guard let promiseDetails = promiseValueDetails else {
Logger.shared.log("extractDetails did not return a Promise",type: "Error")
completion([], [])
return
}
let thenBlockDetails: @convention(block) (JSValue) -> Void = { result in
Logger.shared.log(result.toString(),type: "Debug")
if let jsonOfDetails = result.toString(),
let dataDetails = jsonOfDetails.data(using: .utf8) {
do {
if let array = try JSONSerialization.jsonObject(with: dataDetails, options: []) as? [[String: Any]] {
resultItems = array.map { item -> MediaItem in
MediaItem(
description: item["description"] as? String ?? "",
aliases: item["aliases"] as? String ?? "",
airdate: item["airdate"] as? String ?? ""
)
}
} else {
Logger.shared.log("Failed to parse JSON of extractDetails",type: "Error")
}
} catch {
Logger.shared.log("JSON parsing error of extract details: \(error)",type: "Error")
}
} else {
Logger.shared.log("Result is not a string of extractDetails",type: "Error")
}
dispatchGroup.leave()
}
let catchBlockDetails: @convention(block) (JSValue) -> Void = { error in
Logger.shared.log("Promise rejected of extractDetails: \(String(describing: error.toString()))",type: "Error")
dispatchGroup.leave()
}
let thenFunctionDetails = JSValue(object: thenBlockDetails, in: context)
let catchFunctionDetails = JSValue(object: catchBlockDetails, in: context)
promiseDetails.invokeMethod("then", withArguments: [thenFunctionDetails as Any])
promiseDetails.invokeMethod("catch", withArguments: [catchFunctionDetails as Any])
dispatchGroup.enter()
let promiseValueEpisodes = extractEpisodesFunction.call(withArguments: [url.absoluteString])
guard let promiseEpisodes = promiseValueEpisodes else {
Logger.shared.log("extractEpisodes did not return a Promise",type: "Error")
completion([], [])
return
}
let thenBlockEpisodes: @convention(block) (JSValue) -> Void = { result in
Logger.shared.log(result.toString(),type: "Debug")
if let jsonOfEpisodes = result.toString(),
let dataEpisodes = jsonOfEpisodes.data(using: .utf8) {
do {
if let array = try JSONSerialization.jsonObject(with: dataEpisodes, options: []) as? [[String: Any]] {
episodeLinks = array.map { item -> EpisodeLink in
EpisodeLink(
number: item["number"] as? Int ?? 0,
href: item["href"] as? String ?? ""
)
}
} else {
Logger.shared.log("Failed to parse JSON of extractEpisodes",type: "Error")
}
} catch {
Logger.shared.log("JSON parsing error of extractEpisodes: \(error)",type: "Error")
}
} else {
Logger.shared.log("Result is not a string of extractEpisodes",type: "Error")
}
dispatchGroup.leave()
}
let catchBlockEpisodes: @convention(block) (JSValue) -> Void = { error in
Logger.shared.log("Promise rejected of extractEpisodes: \(String(describing: error.toString()))",type: "Error")
dispatchGroup.leave()
}
let thenFunctionEpisodes = JSValue(object: thenBlockEpisodes, in: context)
let catchFunctionEpisodes = JSValue(object: catchBlockEpisodes, in: context)
promiseEpisodes.invokeMethod("then", withArguments: [thenFunctionEpisodes as Any])
promiseEpisodes.invokeMethod("catch", withArguments: [catchFunctionEpisodes as Any])
dispatchGroup.notify(queue: .main) {
completion(resultItems, episodeLinks)
}
}
}

View file

@ -0,0 +1,129 @@
//
// JSController-Search.swift
// Sulfur
//
// Created by Francesco on 30/03/25.
//
import JavaScriptCore
extension JSController {
func fetchSearchResults(keyword: String, module: ScrapingModule, completion: @escaping ([SearchItem]) -> Void) {
let searchUrl = module.metadata.searchBaseUrl.replacingOccurrences(of: "%s", with: keyword.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")
guard let url = URL(string: searchUrl) else {
completion([])
return
}
URLSession.custom.dataTask(with: url) { [weak self] data, _, error in
guard let self = self else { return }
if let error = error {
Logger.shared.log("Network error: \(error)",type: "Error")
DispatchQueue.main.async { completion([]) }
return
}
guard let data = data, let html = String(data: data, encoding: .utf8) else {
Logger.shared.log("Failed to decode HTML",type: "Error")
DispatchQueue.main.async { completion([]) }
return
}
Logger.shared.log(html,type: "HTMLStrings")
if let parseFunction = self.context.objectForKeyedSubscript("searchResults"),
let results = parseFunction.call(withArguments: [html]).toArray() as? [[String: String]] {
let resultItems = results.map { item in
SearchItem(
title: item["title"] ?? "",
imageUrl: item["image"] ?? "",
href: item["href"] ?? ""
)
}
DispatchQueue.main.async {
completion(resultItems)
}
} else {
Logger.shared.log("Failed to parse results",type: "Error")
DispatchQueue.main.async { completion([]) }
}
}.resume()
}
func fetchJsSearchResults(keyword: String, module: ScrapingModule, completion: @escaping ([SearchItem]) -> Void) {
if let exception = context.exception {
Logger.shared.log("JavaScript exception: \(exception)",type: "Error")
completion([])
return
}
guard let searchResultsFunction = context.objectForKeyedSubscript("searchResults") else {
Logger.shared.log("No JavaScript function searchResults found",type: "Error")
completion([])
return
}
let promiseValue = searchResultsFunction.call(withArguments: [keyword])
guard let promise = promiseValue else {
Logger.shared.log("searchResults did not return a Promise",type: "Error")
completion([])
return
}
let thenBlock: @convention(block) (JSValue) -> Void = { result in
Logger.shared.log(result.toString(),type: "HTMLStrings")
if let jsonString = result.toString(),
let data = jsonString.data(using: .utf8) {
do {
if let array = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] {
let resultItems = array.compactMap { item -> SearchItem? in
guard let title = item["title"] as? String,
let imageUrl = item["image"] as? String,
let href = item["href"] as? String else {
Logger.shared.log("Missing or invalid data in search result item: \(item)", type: "Error")
return nil
}
return SearchItem(title: title, imageUrl: imageUrl, href: href)
}
DispatchQueue.main.async {
completion(resultItems)
}
} else {
Logger.shared.log("Failed to parse JSON",type: "Error")
DispatchQueue.main.async {
completion([])
}
}
} catch {
Logger.shared.log("JSON parsing error: \(error)",type: "Error")
DispatchQueue.main.async {
completion([])
}
}
} else {
Logger.shared.log("Result is not a string",type: "Error")
DispatchQueue.main.async {
completion([])
}
}
}
let catchBlock: @convention(block) (JSValue) -> Void = { error in
Logger.shared.log("Promise rejected: \(String(describing: error.toString()))",type: "Error")
DispatchQueue.main.async {
completion([])
}
}
let thenFunction = JSValue(object: thenBlock, in: context)
let catchFunction = JSValue(object: catchBlock, in: context)
promise.invokeMethod("then", withArguments: [thenFunction as Any])
promise.invokeMethod("catch", withArguments: [catchFunction as Any])
}
}

View file

@ -0,0 +1,307 @@
//
// JSLoader-Streams.swift
// Sulfur
//
// Created by Francesco on 30/03/25.
//
import JavaScriptCore
extension JSController {
func fetchStreamUrl(episodeUrl: String, softsub: Bool = false, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) {
guard let url = URL(string: episodeUrl) else {
completion((nil, nil))
return
}
URLSession.custom.dataTask(with: url) { [weak self] data, _, error in
guard let self = self else { return }
if let error = error {
Logger.shared.log("Network error: \(error)", type: "Error")
DispatchQueue.main.async { completion((nil, nil)) }
return
}
guard let data = data, let html = String(data: data, encoding: .utf8) else {
Logger.shared.log("Failed to decode HTML", type: "Error")
DispatchQueue.main.async { completion((nil, nil)) }
return
}
Logger.shared.log(html, type: "HTMLStrings")
if let parseFunction = self.context.objectForKeyedSubscript("extractStreamUrl"),
let resultString = parseFunction.call(withArguments: [html]).toString() {
if softsub {
if let data = resultString.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
let moduleMetadata = self.context.objectForKeyedSubscript("module")?.objectForKeyedSubscript("metadata")
let isMultiStream = moduleMetadata?.objectForKeyedSubscript("multiStream")?.toBool() == true
let isMultiSubs = moduleMetadata?.objectForKeyedSubscript("multiSubs")?.toBool() == true
var streamUrls: [String]?
if isMultiStream, let streamsArray = json["streams"] as? [String] {
streamUrls = streamsArray
Logger.shared.log("Found \(streamsArray.count) streams", type: "Stream")
} else if let streamUrl = json["stream"] as? String {
streamUrls = [streamUrl]
Logger.shared.log("Found single stream", type: "Stream")
}
var subtitleUrls: [String]?
if isMultiSubs, let subsArray = json["subtitles"] as? [String] {
subtitleUrls = subsArray
Logger.shared.log("Found \(subsArray.count) subtitle tracks", type: "Stream")
} else if let subtitleUrl = json["subtitles"] as? String {
subtitleUrls = [subtitleUrl]
Logger.shared.log("Found single subtitle track", type: "Stream")
}
Logger.shared.log("Starting stream with \(streamUrls?.count ?? 0) sources and \(subtitleUrls?.count ?? 0) subtitles", type: "Stream")
DispatchQueue.main.async {
completion((streamUrls, subtitleUrls))
}
} else {
Logger.shared.log("Failed to parse softsub JSON", type: "Error")
DispatchQueue.main.async { completion((nil, nil)) }
}
} else {
let moduleMetadata = self.context.objectForKeyedSubscript("module")?.objectForKeyedSubscript("metadata")
let isMultiStream = moduleMetadata?.objectForKeyedSubscript("multiStream")?.toBool() == true
if isMultiStream {
if let data = resultString.data(using: .utf8),
let streamsArray = try? JSONSerialization.jsonObject(with: data, options: []) as? [String] {
Logger.shared.log("Starting multi-stream with \(streamsArray.count) sources", type: "Stream")
DispatchQueue.main.async { completion((streamsArray, nil)) }
} else {
Logger.shared.log("Failed to parse multi-stream JSON array", type: "Error")
DispatchQueue.main.async { completion((nil, nil)) }
}
} else {
Logger.shared.log("Starting stream from: \(resultString)", type: "Stream")
DispatchQueue.main.async { completion(([resultString], nil)) }
}
}
} else {
Logger.shared.log("Failed to extract stream URL", type: "Error")
DispatchQueue.main.async { completion((nil, nil)) }
}
}.resume()
}
func fetchStreamUrlJS(episodeUrl: String, softsub: Bool = false, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) {
if let exception = context.exception {
Logger.shared.log("JavaScript exception: \(exception)", type: "Error")
completion((nil, nil))
return
}
guard let extractStreamUrlFunction = context.objectForKeyedSubscript("extractStreamUrl") else {
Logger.shared.log("No JavaScript function extractStreamUrl found", type: "Error")
completion((nil, nil))
return
}
let promiseValue = extractStreamUrlFunction.call(withArguments: [episodeUrl])
guard let promise = promiseValue else {
Logger.shared.log("extractStreamUrl did not return a Promise", type: "Error")
completion((nil, nil))
return
}
let thenBlock: @convention(block) (JSValue) -> Void = { [weak self] result in
guard let self = self else { return }
if softsub {
if let jsonString = result.toString(),
let data = jsonString.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
let moduleMetadata = self.context.objectForKeyedSubscript("module")?.objectForKeyedSubscript("metadata")
let isMultiStream = moduleMetadata?.objectForKeyedSubscript("multiStream")?.toBool() == true
let isMultiSubs = moduleMetadata?.objectForKeyedSubscript("multiSubs")?.toBool() == true
var streamUrls: [String]?
if isMultiStream, let streamsArray = json["streams"] as? [String] {
streamUrls = streamsArray
Logger.shared.log("Found \(streamsArray.count) streams", type: "Stream")
} else if let streamUrl = json["stream"] as? String {
streamUrls = [streamUrl]
Logger.shared.log("Found single stream", type: "Stream")
}
var subtitleUrls: [String]?
if isMultiSubs, let subsArray = json["subtitles"] as? [String] {
subtitleUrls = subsArray
Logger.shared.log("Found \(subsArray.count) subtitle tracks", type: "Stream")
} else if let subtitleUrl = json["subtitles"] as? String {
subtitleUrls = [subtitleUrl]
Logger.shared.log("Found single subtitle track", type: "Stream")
}
Logger.shared.log("Starting stream with \(streamUrls?.count ?? 0) sources and \(subtitleUrls?.count ?? 0) subtitles", type: "Stream")
DispatchQueue.main.async {
completion((streamUrls, subtitleUrls))
}
} else {
Logger.shared.log("Failed to parse softsub JSON in JS", type: "Error")
DispatchQueue.main.async {
completion((nil, nil))
}
}
} else {
let moduleMetadata = self.context.objectForKeyedSubscript("module")?.objectForKeyedSubscript("metadata")
let isMultiStream = moduleMetadata?.objectForKeyedSubscript("multiStream")?.toBool() == true
if isMultiStream {
if let jsonString = result.toString(),
let data = jsonString.data(using: .utf8),
let streamsArray = try? JSONSerialization.jsonObject(with: data, options: []) as? [String] {
Logger.shared.log("Starting multi-stream with \(streamsArray.count) sources", type: "Stream")
DispatchQueue.main.async { completion((streamsArray, nil)) }
} else {
Logger.shared.log("Failed to parse multi-stream JSON array", type: "Error")
DispatchQueue.main.async { completion((nil, nil)) }
}
} else {
let streamUrl = result.toString()
Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream")
DispatchQueue.main.async {
completion((streamUrl != nil ? [streamUrl!] : nil, nil))
}
}
}
}
let catchBlock: @convention(block) (JSValue) -> Void = { error in
Logger.shared.log("Promise rejected: \(String(describing: error.toString()))", type: "Error")
DispatchQueue.main.async {
completion((nil, nil))
}
}
let thenFunction = JSValue(object: thenBlock, in: context)
let catchFunction = JSValue(object: catchBlock, in: context)
promise.invokeMethod("then", withArguments: [thenFunction as Any])
promise.invokeMethod("catch", withArguments: [catchFunction as Any])
}
func fetchStreamUrlJSSecond(episodeUrl: String, softsub: Bool = false, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) {
let url = URL(string: episodeUrl)!
let task = URLSession.custom.dataTask(with: url) { [weak self] data, response, error in
guard let self = self else { return }
if let error = error {
Logger.shared.log("URLSession error: \(error.localizedDescription)", type: "Error")
DispatchQueue.main.async { completion((nil, nil)) }
return
}
guard let data = data, let htmlString = String(data: data, encoding: .utf8) else {
Logger.shared.log("Failed to fetch HTML data", type: "Error")
DispatchQueue.main.async { completion((nil, nil)) }
return
}
DispatchQueue.main.async {
if let exception = self.context.exception {
Logger.shared.log("JavaScript exception: \(exception)", type: "Error")
completion((nil, nil))
return
}
guard let extractStreamUrlFunction = self.context.objectForKeyedSubscript("extractStreamUrl") else {
Logger.shared.log("No JavaScript function extractStreamUrl found", type: "Error")
completion((nil, nil))
return
}
let promiseValue = extractStreamUrlFunction.call(withArguments: [htmlString])
guard let promise = promiseValue else {
Logger.shared.log("extractStreamUrl did not return a Promise", type: "Error")
completion((nil, nil))
return
}
let thenBlock: @convention(block) (JSValue) -> Void = { [weak self] result in
guard let self = self else { return }
if softsub {
if let jsonString = result.toString(),
let data = jsonString.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
let moduleMetadata = self.context.objectForKeyedSubscript("module")?.objectForKeyedSubscript("metadata")
let isMultiStream = moduleMetadata?.objectForKeyedSubscript("multiStream")?.toBool() == true
let isMultiSubs = moduleMetadata?.objectForKeyedSubscript("multiSubs")?.toBool() == true
var streamUrls: [String]?
if isMultiStream, let streamsArray = json["streams"] as? [String] {
streamUrls = streamsArray
Logger.shared.log("Found \(streamsArray.count) streams", type: "Stream")
} else if let streamUrl = json["stream"] as? String {
streamUrls = [streamUrl]
Logger.shared.log("Found single stream", type: "Stream")
}
var subtitleUrls: [String]?
if isMultiSubs, let subsArray = json["subtitles"] as? [String] {
subtitleUrls = subsArray
Logger.shared.log("Found \(subsArray.count) subtitle tracks", type: "Stream")
} else if let subtitleUrl = json["subtitles"] as? String {
subtitleUrls = [subtitleUrl]
Logger.shared.log("Found single subtitle track", type: "Stream")
}
Logger.shared.log("Starting stream with \(streamUrls?.count ?? 0) sources and \(subtitleUrls?.count ?? 0) subtitles", type: "Stream")
DispatchQueue.main.async {
completion((streamUrls, subtitleUrls))
}
} else {
Logger.shared.log("Failed to parse softsub JSON in JSSecond", type: "Error")
DispatchQueue.main.async {
completion((nil, nil))
}
}
} else {
let moduleMetadata = self.context.objectForKeyedSubscript("module")?.objectForKeyedSubscript("metadata")
let isMultiStream = moduleMetadata?.objectForKeyedSubscript("multiStream")?.toBool() == true
if isMultiStream {
if let jsonString = result.toString(),
let data = jsonString.data(using: .utf8),
let streamsArray = try? JSONSerialization.jsonObject(with: data, options: []) as? [String] {
Logger.shared.log("Starting multi-stream with \(streamsArray.count) sources", type: "Stream")
DispatchQueue.main.async { completion((streamsArray, nil)) }
} else {
Logger.shared.log("Failed to parse multi-stream JSON array", type: "Error")
DispatchQueue.main.async { completion((nil, nil)) }
}
} else {
let streamUrl = result.toString()
Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream")
DispatchQueue.main.async {
completion((streamUrl != nil ? [streamUrl!] : nil, nil))
}
}
}
}
let catchBlock: @convention(block) (JSValue) -> Void = { error in
Logger.shared.log("Promise rejected: \(String(describing: error.toString()))", type: "Error")
DispatchQueue.main.async {
completion((nil, nil))
}
}
let thenFunction = JSValue(object: thenBlock, in: self.context)
let catchFunction = JSValue(object: catchBlock, in: self.context)
promise.invokeMethod("then", withArguments: [thenFunction as Any])
promise.invokeMethod("catch", withArguments: [catchFunction as Any])
}
}
task.resume()
}
}

View file

@ -8,14 +8,14 @@
import JavaScriptCore
class JSController: ObservableObject {
private var context: JSContext
var context: JSContext
init() {
self.context = JSContext()
setupContext()
}
private func setupContext() {
func setupContext() {
context.setupJavaScriptEnvironment()
}
@ -27,482 +27,4 @@ class JSController: ObservableObject {
Logger.shared.log("Error loading script: \(exception)", type: "Error")
}
}
func fetchSearchResults(keyword: String, module: ScrapingModule, completion: @escaping ([SearchItem]) -> Void) {
let searchUrl = module.metadata.searchBaseUrl.replacingOccurrences(of: "%s", with: keyword.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")
guard let url = URL(string: searchUrl) else {
completion([])
return
}
URLSession.custom.dataTask(with: url) { [weak self] data, _, error in
guard let self = self else { return }
if let error = error {
Logger.shared.log("Network error: \(error)",type: "Error")
DispatchQueue.main.async { completion([]) }
return
}
guard let data = data, let html = String(data: data, encoding: .utf8) else {
Logger.shared.log("Failed to decode HTML",type: "Error")
DispatchQueue.main.async { completion([]) }
return
}
Logger.shared.log(html,type: "HTMLStrings")
if let parseFunction = self.context.objectForKeyedSubscript("searchResults"),
let results = parseFunction.call(withArguments: [html]).toArray() as? [[String: String]] {
let resultItems = results.map { item in
SearchItem(
title: item["title"] ?? "",
imageUrl: item["image"] ?? "",
href: item["href"] ?? ""
)
}
DispatchQueue.main.async {
completion(resultItems)
}
} else {
Logger.shared.log("Failed to parse results",type: "Error")
DispatchQueue.main.async { completion([]) }
}
}.resume()
}
func fetchDetails(url: String, completion: @escaping ([MediaItem], [EpisodeLink]) -> Void) {
guard let url = URL(string: url) else {
completion([], [])
return
}
URLSession.custom.dataTask(with: url) { [weak self] data, _, error in
guard let self = self else { return }
if let error = error {
Logger.shared.log("Network error: \(error)",type: "Error")
DispatchQueue.main.async { completion([], []) }
return
}
guard let data = data, let html = String(data: data, encoding: .utf8) else {
Logger.shared.log("Failed to decode HTML",type: "Error")
DispatchQueue.main.async { completion([], []) }
return
}
var resultItems: [MediaItem] = []
var episodeLinks: [EpisodeLink] = []
Logger.shared.log(html,type: "HTMLStrings")
if let parseFunction = self.context.objectForKeyedSubscript("extractDetails"),
let results = parseFunction.call(withArguments: [html]).toArray() as? [[String: String]] {
resultItems = results.map { item in
MediaItem(
description: item["description"] ?? "",
aliases: item["aliases"] ?? "",
airdate: item["airdate"] ?? ""
)
}
} else {
Logger.shared.log("Failed to parse results",type: "Error")
}
if let fetchEpisodesFunction = self.context.objectForKeyedSubscript("extractEpisodes"),
let episodesResult = fetchEpisodesFunction.call(withArguments: [html]).toArray() as? [[String: String]] {
for episodeData in episodesResult {
if let num = episodeData["number"], let link = episodeData["href"], let number = Int(num) {
episodeLinks.append(EpisodeLink(number: number, href: link))
}
}
}
DispatchQueue.main.async {
completion(resultItems, episodeLinks)
}
}.resume()
}
func fetchStreamUrl(episodeUrl: String, softsub: Bool = false, completion: @escaping ((stream: String?, subtitles: String?)) -> Void) {
guard let url = URL(string: episodeUrl) else {
completion((nil, nil))
return
}
URLSession.custom.dataTask(with: url) { [weak self] data, _, error in
guard let self = self else { return }
if let error = error {
Logger.shared.log("Network error: \(error)", type: "Error")
DispatchQueue.main.async { completion((nil, nil)) }
return
}
guard let data = data, let html = String(data: data, encoding: .utf8) else {
Logger.shared.log("Failed to decode HTML", type: "Error")
DispatchQueue.main.async { completion((nil, nil)) }
return
}
Logger.shared.log(html, type: "HTMLStrings")
if let parseFunction = self.context.objectForKeyedSubscript("extractStreamUrl"),
let resultString = parseFunction.call(withArguments: [html]).toString() {
if softsub {
if let data = resultString.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
let streamUrl = json["stream"] as? String
let subtitlesUrl = json["subtitles"] as? String
Logger.shared.log("Starting stream from: \(streamUrl ?? "nil") with subtitles: \(subtitlesUrl ?? "nil")", type: "Stream")
DispatchQueue.main.async {
completion((streamUrl, subtitlesUrl))
}
} else {
Logger.shared.log("Failed to parse softsub JSON", type: "Error")
DispatchQueue.main.async { completion((nil, nil)) }
}
} else {
Logger.shared.log("Starting stream from: \(resultString)", type: "Stream")
DispatchQueue.main.async { completion((resultString, nil)) }
}
} else {
Logger.shared.log("Failed to extract stream URL", type: "Error")
DispatchQueue.main.async { completion((nil, nil)) }
}
}.resume()
}
func fetchJsSearchResults(keyword: String, module: ScrapingModule, completion: @escaping ([SearchItem]) -> Void) {
if let exception = context.exception {
Logger.shared.log("JavaScript exception: \(exception)",type: "Error")
completion([])
return
}
guard let searchResultsFunction = context.objectForKeyedSubscript("searchResults") else {
Logger.shared.log("No JavaScript function searchResults found",type: "Error")
completion([])
return
}
let promiseValue = searchResultsFunction.call(withArguments: [keyword])
guard let promise = promiseValue else {
Logger.shared.log("searchResults did not return a Promise",type: "Error")
completion([])
return
}
let thenBlock: @convention(block) (JSValue) -> Void = { result in
Logger.shared.log(result.toString(),type: "HTMLStrings")
if let jsonString = result.toString(),
let data = jsonString.data(using: .utf8) {
do {
if let array = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] {
let resultItems = array.compactMap { item -> SearchItem? in
guard let title = item["title"] as? String,
let imageUrl = item["image"] as? String,
let href = item["href"] as? String else {
Logger.shared.log("Missing or invalid data in search result item: \(item)", type: "Error")
return nil
}
return SearchItem(title: title, imageUrl: imageUrl, href: href)
}
DispatchQueue.main.async {
completion(resultItems)
}
} else {
Logger.shared.log("Failed to parse JSON",type: "Error")
DispatchQueue.main.async {
completion([])
}
}
} catch {
Logger.shared.log("JSON parsing error: \(error)",type: "Error")
DispatchQueue.main.async {
completion([])
}
}
} else {
Logger.shared.log("Result is not a string",type: "Error")
DispatchQueue.main.async {
completion([])
}
}
}
let catchBlock: @convention(block) (JSValue) -> Void = { error in
Logger.shared.log("Promise rejected: \(String(describing: error.toString()))",type: "Error")
DispatchQueue.main.async {
completion([])
}
}
let thenFunction = JSValue(object: thenBlock, in: context)
let catchFunction = JSValue(object: catchBlock, in: context)
promise.invokeMethod("then", withArguments: [thenFunction as Any])
promise.invokeMethod("catch", withArguments: [catchFunction as Any])
}
func fetchDetailsJS(url: String, completion: @escaping ([MediaItem], [EpisodeLink]) -> Void) {
guard let url = URL(string: url) else {
completion([], [])
return
}
if let exception = context.exception {
Logger.shared.log("JavaScript exception: \(exception)",type: "Error")
completion([], [])
return
}
guard let extractDetailsFunction = context.objectForKeyedSubscript("extractDetails") else {
Logger.shared.log("No JavaScript function extractDetails found",type: "Error")
completion([], [])
return
}
guard let extractEpisodesFunction = context.objectForKeyedSubscript("extractEpisodes") else {
Logger.shared.log("No JavaScript function extractEpisodes found",type: "Error")
completion([], [])
return
}
var resultItems: [MediaItem] = []
var episodeLinks: [EpisodeLink] = []
let dispatchGroup = DispatchGroup()
dispatchGroup.enter()
let promiseValueDetails = extractDetailsFunction.call(withArguments: [url.absoluteString])
guard let promiseDetails = promiseValueDetails else {
Logger.shared.log("extractDetails did not return a Promise",type: "Error")
completion([], [])
return
}
let thenBlockDetails: @convention(block) (JSValue) -> Void = { result in
Logger.shared.log(result.toString(),type: "Debug")
if let jsonOfDetails = result.toString(),
let dataDetails = jsonOfDetails.data(using: .utf8) {
do {
if let array = try JSONSerialization.jsonObject(with: dataDetails, options: []) as? [[String: Any]] {
resultItems = array.map { item -> MediaItem in
MediaItem(
description: item["description"] as? String ?? "",
aliases: item["aliases"] as? String ?? "",
airdate: item["airdate"] as? String ?? ""
)
}
} else {
Logger.shared.log("Failed to parse JSON of extractDetails",type: "Error")
}
} catch {
Logger.shared.log("JSON parsing error of extract details: \(error)",type: "Error")
}
} else {
Logger.shared.log("Result is not a string of extractDetails",type: "Error")
}
dispatchGroup.leave()
}
let catchBlockDetails: @convention(block) (JSValue) -> Void = { error in
Logger.shared.log("Promise rejected of extractDetails: \(String(describing: error.toString()))",type: "Error")
dispatchGroup.leave()
}
let thenFunctionDetails = JSValue(object: thenBlockDetails, in: context)
let catchFunctionDetails = JSValue(object: catchBlockDetails, in: context)
promiseDetails.invokeMethod("then", withArguments: [thenFunctionDetails as Any])
promiseDetails.invokeMethod("catch", withArguments: [catchFunctionDetails as Any])
dispatchGroup.enter()
let promiseValueEpisodes = extractEpisodesFunction.call(withArguments: [url.absoluteString])
guard let promiseEpisodes = promiseValueEpisodes else {
Logger.shared.log("extractEpisodes did not return a Promise",type: "Error")
completion([], [])
return
}
let thenBlockEpisodes: @convention(block) (JSValue) -> Void = { result in
Logger.shared.log(result.toString(),type: "Debug")
if let jsonOfEpisodes = result.toString(),
let dataEpisodes = jsonOfEpisodes.data(using: .utf8) {
do {
if let array = try JSONSerialization.jsonObject(with: dataEpisodes, options: []) as? [[String: Any]] {
episodeLinks = array.map { item -> EpisodeLink in
EpisodeLink(
number: item["number"] as? Int ?? 0,
href: item["href"] as? String ?? ""
)
}
} else {
Logger.shared.log("Failed to parse JSON of extractEpisodes",type: "Error")
}
} catch {
Logger.shared.log("JSON parsing error of extractEpisodes: \(error)",type: "Error")
}
} else {
Logger.shared.log("Result is not a string of extractEpisodes",type: "Error")
}
dispatchGroup.leave()
}
let catchBlockEpisodes: @convention(block) (JSValue) -> Void = { error in
Logger.shared.log("Promise rejected of extractEpisodes: \(String(describing: error.toString()))",type: "Error")
dispatchGroup.leave()
}
let thenFunctionEpisodes = JSValue(object: thenBlockEpisodes, in: context)
let catchFunctionEpisodes = JSValue(object: catchBlockEpisodes, in: context)
promiseEpisodes.invokeMethod("then", withArguments: [thenFunctionEpisodes as Any])
promiseEpisodes.invokeMethod("catch", withArguments: [catchFunctionEpisodes as Any])
dispatchGroup.notify(queue: .main) {
completion(resultItems, episodeLinks)
}
}
func fetchStreamUrlJS(episodeUrl: String, softsub: Bool = false, completion: @escaping ((stream: String?, subtitles: String?)) -> Void) {
if let exception = context.exception {
Logger.shared.log("JavaScript exception: \(exception)", type: "Error")
completion((nil, nil))
return
}
guard let extractStreamUrlFunction = context.objectForKeyedSubscript("extractStreamUrl") else {
Logger.shared.log("No JavaScript function extractStreamUrl found", type: "Error")
completion((nil, nil))
return
}
let promiseValue = extractStreamUrlFunction.call(withArguments: [episodeUrl])
guard let promise = promiseValue else {
Logger.shared.log("extractStreamUrl did not return a Promise", type: "Error")
completion((nil, nil))
return
}
let thenBlock: @convention(block) (JSValue) -> Void = { result in
if softsub {
if let jsonString = result.toString(),
let data = jsonString.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
let streamUrl = json["stream"] as? String
let subtitlesUrl = json["subtitles"] as? String
Logger.shared.log("Starting stream from: \(streamUrl ?? "nil") with subtitles: \(subtitlesUrl ?? "nil")", type: "Stream")
DispatchQueue.main.async {
completion((streamUrl, subtitlesUrl))
}
} else {
Logger.shared.log("Failed to parse softsub JSON in JS", type: "Error")
DispatchQueue.main.async {
completion((nil, nil))
}
}
} else {
let streamUrl = result.toString()
Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream")
DispatchQueue.main.async {
completion((streamUrl, nil))
}
}
}
let catchBlock: @convention(block) (JSValue) -> Void = { error in
Logger.shared.log("Promise rejected: \(String(describing: error.toString()))", type: "Error")
DispatchQueue.main.async {
completion((nil, nil))
}
}
let thenFunction = JSValue(object: thenBlock, in: context)
let catchFunction = JSValue(object: catchBlock, in: context)
promise.invokeMethod("then", withArguments: [thenFunction as Any])
promise.invokeMethod("catch", withArguments: [catchFunction as Any])
}
func fetchStreamUrlJSSecond(episodeUrl: String, softsub: Bool = false, completion: @escaping ((stream: String?, subtitles: String?)) -> Void) {
let url = URL(string: episodeUrl)!
let task = URLSession.custom.dataTask(with: url) { data, response, error in
if let error = error {
Logger.shared.log("URLSession error: \(error.localizedDescription)", type: "Error")
DispatchQueue.main.async { completion((nil, nil)) }
return
}
guard let data = data, let htmlString = String(data: data, encoding: .utf8) else {
Logger.shared.log("Failed to fetch HTML data", type: "Error")
DispatchQueue.main.async { completion((nil, nil)) }
return
}
DispatchQueue.main.async {
if let exception = self.context.exception {
Logger.shared.log("JavaScript exception: \(exception)", type: "Error")
completion((nil, nil))
return
}
guard let extractStreamUrlFunction = self.context.objectForKeyedSubscript("extractStreamUrl") else {
Logger.shared.log("No JavaScript function extractStreamUrl found", type: "Error")
completion((nil, nil))
return
}
let promiseValue = extractStreamUrlFunction.call(withArguments: [htmlString])
guard let promise = promiseValue else {
Logger.shared.log("extractStreamUrl did not return a Promise", type: "Error")
completion((nil, nil))
return
}
let thenBlock: @convention(block) (JSValue) -> Void = { result in
if softsub {
if let jsonString = result.toString(),
let data = jsonString.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
let streamUrl = json["stream"] as? String
let subtitlesUrl = json["subtitles"] as? String
Logger.shared.log("Starting stream from: \(streamUrl ?? "nil") with subtitles: \(subtitlesUrl ?? "nil")", type: "Stream")
DispatchQueue.main.async {
completion((streamUrl, subtitlesUrl))
}
} else {
Logger.shared.log("Failed to parse softsub JSON in JSSecond", type: "Error")
DispatchQueue.main.async {
completion((nil, nil))
}
}
} else {
let streamUrl = result.toString()
Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream")
DispatchQueue.main.async {
completion((streamUrl, nil))
}
}
}
let catchBlock: @convention(block) (JSValue) -> Void = { error in
Logger.shared.log("Promise rejected: \(String(describing: error.toString()))", type: "Error")
DispatchQueue.main.async {
completion((nil, nil))
}
}
let thenFunction = JSValue(object: thenBlock, in: self.context)
let catchFunction = JSValue(object: catchBlock, in: self.context)
promise.invokeMethod("then", withArguments: [thenFunction as Any])
promise.invokeMethod("catch", withArguments: [catchFunction as Any])
}
}
task.resume()
}
}

View file

@ -21,6 +21,8 @@ struct ModuleMetadata: Codable, Hashable {
let asyncJS: Bool?
let streamAsyncJS: Bool?
let softsub: Bool?
let multiStream: Bool?
let multiSubs: Bool?
struct Author: Codable, Hashable {
let name: String

View file

@ -500,8 +500,9 @@ struct MediaInfoView: View {
if module.metadata.softsub == true {
if module.metadata.asyncJS == true {
jsController.fetchStreamUrlJS(episodeUrl: href, softsub: true) { result in
if let streamUrl = result.stream {
self.playStream(url: streamUrl, fullURL: href, subtitles: result.subtitles)
// Use first stream from array
if let streams = result.streams, !streams.isEmpty {
self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first)
} else {
self.handleStreamFailure(error: nil)
}
@ -511,8 +512,8 @@ struct MediaInfoView: View {
}
} else if module.metadata.streamAsyncJS == true {
jsController.fetchStreamUrlJSSecond(episodeUrl: href, softsub: true) { result in
if let streamUrl = result.stream {
self.playStream(url: streamUrl, fullURL: href, subtitles: result.subtitles)
if let streams = result.streams, !streams.isEmpty {
self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first)
} else {
self.handleStreamFailure(error: nil)
}
@ -522,8 +523,8 @@ struct MediaInfoView: View {
}
} else {
jsController.fetchStreamUrl(episodeUrl: href, softsub: true) { result in
if let streamUrl = result.stream {
self.playStream(url: streamUrl, fullURL: href, subtitles: result.subtitles)
if let streams = result.streams, !streams.isEmpty {
self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first)
} else {
self.handleStreamFailure(error: nil)
}
@ -535,8 +536,8 @@ struct MediaInfoView: View {
} else {
if module.metadata.asyncJS == true {
jsController.fetchStreamUrlJS(episodeUrl: href) { result in
if let streamUrl = result.stream {
self.playStream(url: streamUrl, fullURL: href)
if let streams = result.streams, !streams.isEmpty {
self.playStream(url: streams[0], fullURL: href)
} else {
self.handleStreamFailure(error: nil)
}
@ -546,8 +547,8 @@ struct MediaInfoView: View {
}
} else if module.metadata.streamAsyncJS == true {
jsController.fetchStreamUrlJSSecond(episodeUrl: href) { result in
if let streamUrl = result.stream {
self.playStream(url: streamUrl, fullURL: href)
if let streams = result.streams, !streams.isEmpty {
self.playStream(url: streams[0], fullURL: href)
} else {
self.handleStreamFailure(error: nil)
}
@ -557,8 +558,8 @@ struct MediaInfoView: View {
}
} else {
jsController.fetchStreamUrl(episodeUrl: href) { result in
if let streamUrl = result.stream {
self.playStream(url: streamUrl, fullURL: href)
if let streams = result.streams, !streams.isEmpty {
self.playStream(url: streams[0], fullURL: href)
} else {
self.handleStreamFailure(error: nil)
}

View file

@ -15,6 +15,9 @@
131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */; };
1327FBA72D758CEA00FC6689 /* Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1327FBA62D758CEA00FC6689 /* Analytics.swift */; };
1327FBA92D758DEA00FC6689 /* UIDevice+Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */; };
132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1202D99951700A0140B /* JSController-Streams.swift */; };
132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1222D9995C300A0140B /* JSController-Details.swift */; };
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1242D9995F900A0140B /* JSController-Search.swift */; };
132E351D2D959DDB0007800E /* Drops in Frameworks */ = {isa = PBXBuildFile; productRef = 132E351C2D959DDB0007800E /* Drops */; };
132E35202D959E1D0007800E /* FFmpeg-iOS-Lame in Frameworks */ = {isa = PBXBuildFile; productRef = 132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */; };
132E35232D959E410007800E /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 132E35222D959E410007800E /* Kingfisher */; };
@ -74,6 +77,9 @@
131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewGeneral.swift; sourceTree = "<group>"; };
1327FBA62D758CEA00FC6689 /* Analytics.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Analytics.swift; sourceTree = "<group>"; };
1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIDevice+Model.swift"; sourceTree = "<group>"; };
132AF1202D99951700A0140B /* JSController-Streams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Streams.swift"; sourceTree = "<group>"; };
132AF1222D9995C300A0140B /* JSController-Details.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Details.swift"; sourceTree = "<group>"; };
132AF1242D9995F900A0140B /* JSController-Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Search.swift"; sourceTree = "<group>"; };
1334FF4C2D786C93007E289F /* TMDB-Seasonal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TMDB-Seasonal.swift"; sourceTree = "<group>"; };
1334FF4E2D786C9E007E289F /* TMDB-Trending.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TMDB-Trending.swift"; sourceTree = "<group>"; };
1334FF512D7871B7007E289F /* TMDBItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TMDBItem.swift; sourceTree = "<group>"; };
@ -326,6 +332,9 @@
isa = PBXGroup;
children = (
133D7C8B2D2BE2640075467E /* JSController.swift */,
132AF1202D99951700A0140B /* JSController-Streams.swift */,
132AF1222D9995C300A0140B /* JSController-Details.swift */,
132AF1242D9995F900A0140B /* JSController-Search.swift */,
);
path = JSLoader;
sourceTree = "<group>";
@ -524,6 +533,7 @@
13B7F4C12D58FFDD0045714A /* Shimmer.swift in Sources */,
139935662D468C450065CEFF /* ModuleManager.swift in Sources */,
133D7C902D2BE2640075467E /* SettingsView.swift in Sources */,
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */,
1334FF4F2D786C9E007E289F /* TMDB-Trending.swift in Sources */,
13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */,
13DB46902D900A38008CBC03 /* URL.swift in Sources */,
@ -542,6 +552,7 @@
13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */,
133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */,
13103E892D58A39A000F0673 /* AniListItem.swift in Sources */,
132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */,
131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */,
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */,
13D842552D45267500EBBFA6 /* DropManager.swift in Sources */,
@ -562,6 +573,7 @@
1334FF4D2D786C93007E289F /* TMDB-Seasonal.swift in Sources */,
13DB468D2D90093A008CBC03 /* Anilist-Login.swift in Sources */,
73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */,
132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */,
13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */,
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */,
13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */,