Files
yattee/Model/Applications/VideosAPI.swift
Arkadiusz Fal 41a33634ee Fix YouTube share links including port from Invidious instance
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.
2025-11-23 12:00:02 +01:00

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 []
}
}