mirror of
https://github.com/yattee/yattee.git
synced 2025-11-24 18:28:20 +00:00
Fixes #870 where YouTube share links incorrectly included the port number from the user's Invidious instance URL (e.g., http://www.youtube.com:3000 instead of http://www.youtube.com). Added defensive logic to explicitly clear the port when creating share URLs with frontend URL strings containing "youtube.com". This ensures YouTube share links always use the standard format without port numbers, regardless of the user's instance configuration.
252 lines
9.5 KiB
Swift
252 lines
9.5 KiB
Swift
import AVFoundation
|
|
import Foundation
|
|
import Siesta
|
|
|
|
protocol VideosAPI {
|
|
var account: Account! { get }
|
|
var signedIn: Bool { get }
|
|
|
|
static func withAnonymousAccountForInstanceURL(_ url: URL) -> Self
|
|
|
|
func channel(_ id: String, contentType: Channel.ContentType, data: String?, page: String?) -> Resource
|
|
func channelByName(_ name: String) -> Resource?
|
|
func channelByUsername(_ username: String) -> Resource?
|
|
func channelVideos(_ id: String) -> Resource
|
|
func trending(country: Country, category: TrendingCategory?) -> Resource
|
|
func search(_ query: SearchQuery, page: String?) -> Resource
|
|
func searchSuggestions(query: String) -> Resource
|
|
|
|
func video(_ id: Video.ID) -> Resource
|
|
|
|
func feed(_ page: Int?) -> Resource?
|
|
var subscriptions: Resource? { get }
|
|
var home: Resource? { get }
|
|
var popular: Resource? { get }
|
|
var playlists: Resource? { get }
|
|
|
|
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void)
|
|
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void)
|
|
|
|
func playlist(_ id: String) -> Resource?
|
|
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource?
|
|
func playlistVideos(_ id: String) -> Resource?
|
|
|
|
func addVideoToPlaylist(
|
|
_ videoID: String,
|
|
_ playlistID: String,
|
|
onFailure: @escaping (RequestError) -> Void,
|
|
onSuccess: @escaping () -> Void
|
|
)
|
|
|
|
func removeVideoFromPlaylist(
|
|
_ index: String,
|
|
_ playlistID: String,
|
|
onFailure: @escaping (RequestError) -> Void,
|
|
onSuccess: @escaping () -> Void
|
|
)
|
|
|
|
func playlistForm(
|
|
_ name: String,
|
|
_ visibility: String,
|
|
playlist: Playlist?,
|
|
onFailure: @escaping (RequestError) -> Void,
|
|
onSuccess: @escaping (Playlist?) -> Void
|
|
)
|
|
|
|
func deletePlaylist(
|
|
_ playlist: Playlist,
|
|
onFailure: @escaping (RequestError) -> Void,
|
|
onSuccess: @escaping () -> Void
|
|
)
|
|
|
|
func channelPlaylist(_ id: String) -> Resource?
|
|
|
|
func loadDetails(
|
|
_ item: PlayerQueueItem,
|
|
failureHandler: ((RequestError) -> Void)?,
|
|
completionHandler: @escaping (PlayerQueueItem) -> Void
|
|
)
|
|
func shareURL(_ item: ContentItem, frontendURLString: String?, time: CMTime?) -> URL?
|
|
|
|
func comments(_ id: Video.ID, page: String?) -> Resource?
|
|
}
|
|
|
|
extension VideosAPI {
|
|
func channel(_ id: String, contentType: Channel.ContentType, data: String? = nil, page: String? = nil) -> Resource {
|
|
channel(id, contentType: contentType, data: data, page: page)
|
|
}
|
|
|
|
func loadDetails(
|
|
_ item: PlayerQueueItem,
|
|
failureHandler: ((RequestError) -> Void)? = nil,
|
|
completionHandler: @escaping (PlayerQueueItem) -> Void = { _ in }
|
|
) {
|
|
guard (item.video?.streams ?? []).isEmpty else {
|
|
completionHandler(item)
|
|
return
|
|
}
|
|
|
|
if let video = item.video, video.isLocal {
|
|
completionHandler(item)
|
|
return
|
|
}
|
|
|
|
video(item.videoID).load()
|
|
.onSuccess { response in
|
|
guard let video: Video = response.typedContent() else {
|
|
return
|
|
}
|
|
|
|
VideosCacheModel.shared.storeVideo(video)
|
|
|
|
var newItem = item
|
|
newItem.id = UUID()
|
|
newItem.video = video
|
|
|
|
completionHandler(newItem)
|
|
}
|
|
.onFailure { failureHandler?($0) }
|
|
}
|
|
|
|
func shareURL(_ item: ContentItem, frontendURLString: String? = nil, time: CMTime? = nil) -> URL? {
|
|
var urlComponents: URLComponents?
|
|
if let frontendURLString,
|
|
let frontendURL = URL(string: frontendURLString)
|
|
{
|
|
urlComponents = URLComponents(url: frontendURL, resolvingAgainstBaseURL: false)
|
|
// Ensure port is not included when sharing to external frontends like YouTube
|
|
if frontendURLString.contains("youtube.com") {
|
|
urlComponents?.port = nil
|
|
}
|
|
} else if let instanceComponents = account?.instance?.urlComponents {
|
|
urlComponents = instanceComponents
|
|
}
|
|
|
|
guard var urlComponents else {
|
|
return nil
|
|
}
|
|
|
|
var queryItems = [URLQueryItem]()
|
|
|
|
switch item.contentType {
|
|
case .video:
|
|
urlComponents.path = "/watch"
|
|
queryItems.append(.init(name: "v", value: item.video.videoID))
|
|
case .channel:
|
|
urlComponents.path = "/channel/\(item.channel.id)"
|
|
case .playlist:
|
|
urlComponents.path = "/playlist"
|
|
queryItems.append(.init(name: "list", value: item.playlist.id))
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
if !time.isNil, time!.seconds.isFinite {
|
|
queryItems.append(.init(name: "t", value: "\(Int(time!.seconds))s"))
|
|
}
|
|
|
|
if !queryItems.isEmpty {
|
|
urlComponents.queryItems = queryItems
|
|
}
|
|
|
|
return urlComponents.url
|
|
}
|
|
|
|
func extractChapters(from description: String) -> [Chapter] {
|
|
/*
|
|
The following chapter patterns are covered:
|
|
|
|
1) "start - end - title" / "start - end: Title" / "start - end title"
|
|
2) "start - title" / "start: title" / "start title" / "[start] - title" / "[start]: title" / "[start] title"
|
|
3) "index. title - start" / "index. title start"
|
|
4) "title: (start)"
|
|
5) "(start) title"
|
|
|
|
These represent:
|
|
|
|
- "start" and "end" are timestamps, defining the start and end of the individual chapter
|
|
- "title" is the name of the chapter
|
|
- "index" is the chapter's position in a list
|
|
|
|
The order of these patterns is important as it determines the priority. The patterns listed first have a higher priority.
|
|
In the case of multiple matches, the pattern with the highest priority will be chosen - lower number means higher priority.
|
|
*/
|
|
let patterns = [
|
|
"(?<=\\n|^)\\s*(?:►\\s*)?\\[?(?<start>(?:[0-9]+:){1,2}[0-9]+)\\]?(?:\\s*-\\s*)?(?<end>(?:[0-9]+:){1,2}[0-9]+)?(?:\\s*-\\s*|\\s*[:]\\s*)?(?<title>.*)(?=\\n|$)",
|
|
"(?<=\\n|^)\\s*(?:►\\s*)?\\[?(?<start>(?:[0-9]+:){1,2}[0-9]+)\\]?\\s*[-:]?\\s*(?<title>.+)(?=\\n|$)",
|
|
"(?<=\\n|^)(?<index>[0-9]+\\.\\s)(?<title>.+?)(?:\\s*-\\s*)?(?<start>(?:[0-9]+:){1,2}[0-9]+)(?=\\n|$)",
|
|
"(?<=\\n|^)(?<title>.+?):\\s*\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)(?=\\n|$)",
|
|
"(?<=^|\\n)\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)\\s*(?<title>.+?)(?=\\n|$)"
|
|
]
|
|
|
|
let extractChaptersGroup = DispatchGroup()
|
|
var capturedChapters: [Int: [Chapter]] = [:]
|
|
let lock = NSLock()
|
|
|
|
for (index, pattern) in patterns.enumerated() {
|
|
extractChaptersGroup.enter()
|
|
DispatchQueue.global().async {
|
|
if let chaptersRegularExpression = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) {
|
|
let chapterLines = chaptersRegularExpression.matches(in: description, range: NSRange(description.startIndex..., in: description))
|
|
let extractedChapters = chapterLines.compactMap { line -> Chapter? in
|
|
let titleRange = line.range(withName: "title")
|
|
let startRange = line.range(withName: "start")
|
|
|
|
guard let titleSubstringRange = Range(titleRange, in: description),
|
|
let startSubstringRange = Range(startRange, in: description)
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
let titleCapture = String(description[titleSubstringRange]).trimmingCharacters(in: .whitespaces)
|
|
let startCapture = String(description[startSubstringRange])
|
|
let startComponents = startCapture.components(separatedBy: ":")
|
|
guard startComponents.count <= 3 else { return nil }
|
|
|
|
var hours: Double?
|
|
var minutes: Double?
|
|
var seconds: Double?
|
|
|
|
if startComponents.count == 3 {
|
|
hours = Double(startComponents[0])
|
|
minutes = Double(startComponents[1])
|
|
seconds = Double(startComponents[2])
|
|
} else if startComponents.count == 2 {
|
|
minutes = Double(startComponents[0])
|
|
seconds = Double(startComponents[1])
|
|
}
|
|
|
|
guard var startSeconds = seconds else { return nil }
|
|
|
|
startSeconds += (minutes ?? 0) * 60
|
|
startSeconds += (hours ?? 0) * 60 * 60
|
|
|
|
return Chapter(title: titleCapture, start: startSeconds)
|
|
}
|
|
|
|
if !extractedChapters.isEmpty {
|
|
lock.lock()
|
|
capturedChapters[index] = extractedChapters
|
|
lock.unlock()
|
|
}
|
|
}
|
|
extractChaptersGroup.leave()
|
|
}
|
|
}
|
|
|
|
extractChaptersGroup.wait()
|
|
|
|
// Now we sort the keys of the capturedChapters dictionary.
|
|
// These keys correspond to the priority of each pattern.
|
|
let sortedKeys = Array(capturedChapters.keys).sorted(by: <)
|
|
|
|
// Return first non-empty result in the order of patterns
|
|
for key in sortedKeys {
|
|
if let chapters = capturedChapters[key], !chapters.isEmpty {
|
|
return chapters
|
|
}
|
|
}
|
|
return []
|
|
}
|
|
}
|