From 6243d456ca77122768099fd3d84a9b493e598150 Mon Sep 17 00:00:00 2001 From: Francesco <100066266+cranci1@users.noreply.github.com> Date: Sun, 30 Mar 2025 17:25:39 +0200 Subject: [PATCH] made it use an Array if needed --- .../Utils/JSLoader/JSController-Details.swift | 185 +++++++ Sora/Utils/JSLoader/JSController-Search.swift | 129 +++++ .../Utils/JSLoader/JSController-Streams.swift | 307 +++++++++++ Sora/Utils/JSLoader/JSController.swift | 482 +----------------- Sora/Utils/Modules/Modules.swift | 2 + Sora/Views/MediaInfoView/MediaInfoView.swift | 25 +- Sulfur.xcodeproj/project.pbxproj | 12 + 7 files changed, 650 insertions(+), 492 deletions(-) create mode 100644 Sora/Utils/JSLoader/JSController-Details.swift create mode 100644 Sora/Utils/JSLoader/JSController-Search.swift create mode 100644 Sora/Utils/JSLoader/JSController-Streams.swift diff --git a/Sora/Utils/JSLoader/JSController-Details.swift b/Sora/Utils/JSLoader/JSController-Details.swift new file mode 100644 index 0000000..8ba89b9 --- /dev/null +++ b/Sora/Utils/JSLoader/JSController-Details.swift @@ -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) + } + } +} diff --git a/Sora/Utils/JSLoader/JSController-Search.swift b/Sora/Utils/JSLoader/JSController-Search.swift new file mode 100644 index 0000000..007019e --- /dev/null +++ b/Sora/Utils/JSLoader/JSController-Search.swift @@ -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]) + } +} diff --git a/Sora/Utils/JSLoader/JSController-Streams.swift b/Sora/Utils/JSLoader/JSController-Streams.swift new file mode 100644 index 0000000..58a5148 --- /dev/null +++ b/Sora/Utils/JSLoader/JSController-Streams.swift @@ -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() + } +} diff --git a/Sora/Utils/JSLoader/JSController.swift b/Sora/Utils/JSLoader/JSController.swift index a0a220b..2485ded 100644 --- a/Sora/Utils/JSLoader/JSController.swift +++ b/Sora/Utils/JSLoader/JSController.swift @@ -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() - } } diff --git a/Sora/Utils/Modules/Modules.swift b/Sora/Utils/Modules/Modules.swift index c5f93e4..b22d7cb 100644 --- a/Sora/Utils/Modules/Modules.swift +++ b/Sora/Utils/Modules/Modules.swift @@ -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 diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 6e1af48..786decb 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -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) } diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index ad75420..7915612 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -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 = ""; }; 1327FBA62D758CEA00FC6689 /* Analytics.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Analytics.swift; sourceTree = ""; }; 1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIDevice+Model.swift"; sourceTree = ""; }; + 132AF1202D99951700A0140B /* JSController-Streams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Streams.swift"; sourceTree = ""; }; + 132AF1222D9995C300A0140B /* JSController-Details.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Details.swift"; sourceTree = ""; }; + 132AF1242D9995F900A0140B /* JSController-Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Search.swift"; sourceTree = ""; }; 1334FF4C2D786C93007E289F /* TMDB-Seasonal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TMDB-Seasonal.swift"; sourceTree = ""; }; 1334FF4E2D786C9E007E289F /* TMDB-Trending.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TMDB-Trending.swift"; sourceTree = ""; }; 1334FF512D7871B7007E289F /* TMDBItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TMDBItem.swift; sourceTree = ""; }; @@ -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 = ""; @@ -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 */,