yattee/Shared/URLParser.swift

231 lines
6.6 KiB
Swift
Raw Normal View History

2022-06-24 22:48:57 +00:00
import CoreMedia
import Foundation
struct URLParser {
2022-08-23 15:07:04 +00:00
static var shortsPrefix = "/shorts/"
2022-06-24 22:48:57 +00:00
static let prefixes: [Destination: [String]] = [
.playlist: ["/playlist", "playlist"],
2022-06-29 23:31:51 +00:00
.channel: ["/c", "c", "/channel", "channel", "/user", "user"],
2022-06-24 22:48:57 +00:00
.search: ["/results", "search"]
]
enum Destination {
case fileURL, video, playlist, channel, search
2022-06-24 22:48:57 +00:00
case favorites, subscriptions, popular, trending
}
2022-06-29 23:31:51 +00:00
var url: URL
2022-11-18 21:27:21 +00:00
var allowFileURLs = true
2022-06-29 23:31:51 +00:00
2022-11-18 21:27:21 +00:00
init(url: URL, allowFileURLs: Bool = true) {
2022-06-29 23:31:51 +00:00
self.url = url
2022-11-18 21:27:21 +00:00
self.allowFileURLs = allowFileURLs
2022-06-29 23:31:51 +00:00
let urlString = url.absoluteString
let scheme = urlComponents?.scheme
if scheme == nil,
2022-11-18 21:27:21 +00:00
urlString.contains("youtube.com") ||
urlString.contains("youtu.be") ||
urlString.contains("youtube-nocookie.com"),
2022-06-29 23:31:51 +00:00
let url = URL(string: "https://\(urlString)"
)
{
self.url = url
}
}
2022-06-24 22:48:57 +00:00
var destination: Destination? {
if hasAnyOfPrefixes(path, ["favorites"]) { return .favorites }
if hasAnyOfPrefixes(path, ["subscriptions"]) { return .subscriptions }
if hasAnyOfPrefixes(path, ["popular"]) { return .popular }
if hasAnyOfPrefixes(path, ["trending"]) { return .trending }
if hasAnyOfPrefixes(path, Self.prefixes[.playlist]!) ||
queryItemValue("v") == "playlist" ||
!(queryItemValue("list")?.isEmpty ?? false)
{
2022-06-24 22:48:57 +00:00
return .playlist
2023-06-17 12:09:51 +00:00
}
if hasAnyOfPrefixes(path, Self.prefixes[.channel]!) {
2022-06-24 22:48:57 +00:00
return .channel
2023-06-17 12:09:51 +00:00
}
if hasAnyOfPrefixes(path, Self.prefixes[.search]!) {
2022-06-24 22:48:57 +00:00
return .search
}
guard let id = videoID, !id.isEmpty else {
2022-08-21 22:37:29 +00:00
if isYoutubeHost {
return .channel
}
2022-11-18 21:27:21 +00:00
return allowFileURLs ? .fileURL : nil
2022-06-24 22:48:57 +00:00
}
return .video
}
2022-08-21 22:37:29 +00:00
var isYoutubeHost: Bool {
2022-09-28 14:27:01 +00:00
guard let urlComponents else { return false }
2022-11-18 21:27:21 +00:00
let hostComponents = (urlComponents.host ?? "").components(separatedBy: ".").prefix(2)
2022-08-21 22:37:29 +00:00
2022-11-18 21:27:21 +00:00
if hostComponents.contains("youtube") || hostComponents.contains("youtube-nocookie") {
return true
}
let host = hostComponents.joined(separator: ".")
.replacingFirstOccurrence(of: "www.", with: "")
return host == "youtu.be"
}
var isYoutube: Bool {
guard let urlComponents else { return false }
return urlComponents.host == "youtube.com" || urlComponents.host == "www.youtube.com" || urlComponents.host == "youtu.be"
2022-08-21 22:37:29 +00:00
}
2022-08-23 15:07:04 +00:00
var isShortsPath: Bool {
path.hasPrefix(Self.shortsPrefix)
}
var fileURL: URL? {
2022-11-18 21:27:21 +00:00
guard allowFileURLs, destination == .fileURL else { return nil }
return url
}
2022-06-24 22:48:57 +00:00
var videoID: String? {
if host == "youtu.be", !path.isEmpty {
return String(path.suffix(from: path.index(path.startIndex, offsetBy: 1)))
}
2022-08-23 15:07:04 +00:00
if isYoutubeHost, isShortsPath {
let index = path.index(path.startIndex, offsetBy: Self.shortsPrefix.count)
return String(path[index...])
}
2022-06-24 22:48:57 +00:00
return queryItemValue("v")
}
var time: Int? {
guard let time = queryItemValue("t") else {
return nil
}
let timeComponents = parseTime(time)
guard !timeComponents.isEmpty,
let hours = Int(timeComponents["hours"] ?? "0"),
let minutes = Int(timeComponents["minutes"] ?? "0"),
let seconds = Int(timeComponents["seconds"] ?? "0")
else {
return Int(time)
}
return Int(seconds + (minutes * 60) + (hours * 60 * 60))
}
var playlistID: String? {
guard destination == .playlist else { return nil }
return queryItemValue("list")
}
var searchQuery: String? {
guard destination == .search else { return nil }
return queryItemValue("search_query")?.replacingOccurrences(of: "+", with: " ")
}
var channelName: String? {
2022-08-21 22:37:29 +00:00
guard hasAnyOfPrefixes(path, ["c/", "/c/"]) else {
2022-08-23 15:07:04 +00:00
if channelID == nil, username == nil { return pathWithoutForwardSlash }
2022-08-21 22:37:29 +00:00
return nil
}
2022-06-24 22:48:57 +00:00
return removePrefixes(path, Self.prefixes[.channel]!.map { [$0, "/"].joined() })
}
var channelID: String? {
2022-06-26 12:32:15 +00:00
guard hasAnyOfPrefixes(path, ["channel/", "/channel/"]) else { return nil }
2022-06-24 22:48:57 +00:00
return removePrefixes(path, Self.prefixes[.channel]!.map { [$0, "/"].joined() })
}
2022-06-29 23:31:51 +00:00
var username: String? {
guard hasAnyOfPrefixes(path, ["user/", "/user/"]) else { return nil }
return removePrefixes(path, ["user/", "/user/"])
}
2022-06-24 22:48:57 +00:00
private var host: String {
urlComponents?.host ?? ""
}
2022-08-21 22:37:29 +00:00
private var pathWithoutForwardSlash: String {
2022-09-28 14:27:01 +00:00
guard let urlComponents else { return "" }
2022-08-21 22:37:29 +00:00
return String(urlComponents.path.dropFirst())
}
2022-06-24 22:48:57 +00:00
private var path: String {
removePrefixes(urlComponents?.path ?? "", ["www.youtube.com", "youtube.com"])
}
private func hasAnyOfPrefixes(_ value: String, _ prefixes: [String]) -> Bool {
2022-06-26 12:32:15 +00:00
prefixes.contains { value.hasPrefix($0) }
2022-06-24 22:48:57 +00:00
}
private func removePrefixes(_ value: String, _ prefixes: [String]) -> String {
var value = value
prefixes.forEach { prefix in
if value.hasPrefix(prefix) {
value.removeFirst(prefix.count)
}
}
return value
}
private var queryItems: [URLQueryItem] {
urlComponents?.queryItems ?? []
}
private func queryItemValue(_ name: String) -> String? {
queryItems.first { $0.name == name }?.value
}
private var urlComponents: URLComponents? {
URLComponents(url: url, resolvingAgainstBaseURL: false)
}
private func parseTime(_ time: String) -> [String: String] {
let results = timeRegularExpression.matches(
in: time,
range: NSRange(time.startIndex..., in: time)
)
guard let match = results.first else {
return [:]
}
var components: [String: String] = [:]
for name in ["hours", "minutes", "seconds"] {
let matchRange = match.range(withName: name)
if let substringRange = Range(matchRange, in: time) {
let capture = String(time[substringRange])
components[name] = capture
}
}
return components
}
private var timeRegularExpression: NSRegularExpression {
try! NSRegularExpression(
pattern: "(?:(?<hours>[0-9+])+h)?(?:(?<minutes>[0-9]+)m)?(?:(?<seconds>[0-9]*)s)?",
options: .caseInsensitive
)
}
}