mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
yattee://play|play-next|queue|download|bookmark|subscribe|unsubscribe?url={link}
and yattee://player/play|pause|toggle|next|previous|close|seek|rate run through
one LinkActionPerformer that reports a LinkActionResult, ready for
x-callback-url. An unencoded url= keeps its own query; only the action's own
parameters end it.
Actions that change saved state ask first through a confirmation sheet
presented by the top-most sheet host, unless the new 'Run Link Actions
Without Asking' setting is on. Download skips the question when the quality
picker will show anyway. Player commands leave sheets and the player alone.
522 lines
20 KiB
Swift
522 lines
20 KiB
Swift
//
|
|
// URLRouter.swift
|
|
// Yattee
|
|
//
|
|
// URL parsing and routing for deep links and shared URLs.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// Routes URLs to navigation destinations.
|
|
struct URLRouter: Sendable {
|
|
|
|
// MARK: - Main Routing
|
|
|
|
/// Route a URL only if we are *confident* the app can handle it natively —
|
|
/// YouTube/PeerTube video/channel/playlist, direct media (mp4/m3u8/etc),
|
|
/// or the custom `yattee://` scheme. Unlike `route(_:)` this deliberately
|
|
/// skips the `.externalVideo` yt-dlp fallback, which matches almost any
|
|
/// http/https URL and is therefore unsafe to trigger blindly after
|
|
/// resolving a URL shortener.
|
|
func routeConfidently(_ url: URL) -> NavigationDestination? {
|
|
guard let destination = route(url) else { return nil }
|
|
if case .externalVideo = destination { return nil }
|
|
return destination
|
|
}
|
|
|
|
/// Route a URL to a navigation destination.
|
|
func route(_ url: URL) -> NavigationDestination? {
|
|
// Resolve yattee://open?url=… wrappers up front so every caller gets the
|
|
// inner URL intact (including its own `&t=` / `&list=` query items).
|
|
let url = unwrapped(url)
|
|
|
|
// Try custom scheme first
|
|
if url.scheme?.lowercased() == "yattee" {
|
|
return parseCustomScheme(url)
|
|
}
|
|
|
|
// Try PeerTube playlist URLs before video URLs (`/w/p/{id}` would otherwise
|
|
// parse as a video named "p")
|
|
if let (instance, playlistID) = parsePeerTubePlaylistURL(url) {
|
|
return .playlist(.remote(PlaylistID(source: .federated(provider: ContentSource.peertubeProvider, instance: instance), playlistID: playlistID), instance: nil))
|
|
}
|
|
|
|
// Try YouTube playlist URLs first (before video URLs since playlist pages can have v= param)
|
|
if let playlistID = parseYouTubePlaylistURL(url) {
|
|
return .playlist(.remote(PlaylistID(source: .global(provider: ContentSource.youtubeProvider), playlistID: playlistID), instance: nil))
|
|
}
|
|
|
|
// Try YouTube channel URLs
|
|
if let channelID = parseYouTubeChannelURL(url) {
|
|
return .channel(channelID, .global(provider: ContentSource.youtubeProvider))
|
|
}
|
|
|
|
// Try YouTube video URLs
|
|
if let videoID = parseYouTubeURL(url) {
|
|
return .video(.id(.global(videoID)))
|
|
}
|
|
|
|
// Try PeerTube URLs
|
|
if let (instance, videoID) = parsePeerTubeURL(url) {
|
|
return .video(.id(.federated(videoID, instance: instance, uuid: nil)))
|
|
}
|
|
|
|
// Try direct media URLs (mp4, m3u8, etc.) - no extraction needed.
|
|
// Remote only: a link from another app must never open a local file.
|
|
if isHTTPURL(url), DirectMediaHelper.isDirectMediaURL(url) {
|
|
return .directMedia(url)
|
|
}
|
|
|
|
// Fallback: Try external URL extraction for any http/https URL
|
|
// This will be handled by Yattee Server using yt-dlp
|
|
if isExternalVideoURL(url) {
|
|
return .externalVideo(url)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MARK: - External URL Detection
|
|
|
|
/// Check if URL might be an external video that yt-dlp can handle.
|
|
private func isHTTPURL(_ url: URL) -> Bool {
|
|
let scheme = url.scheme?.lowercased()
|
|
return scheme == "http" || scheme == "https"
|
|
}
|
|
|
|
private func isExternalVideoURL(_ url: URL) -> Bool {
|
|
// Must be http or https
|
|
guard isHTTPURL(url) else {
|
|
return false
|
|
}
|
|
|
|
// Must have a host
|
|
guard let host = url.host?.lowercased(), !host.isEmpty else {
|
|
return false
|
|
}
|
|
|
|
// Skip known non-video sites
|
|
let excludedHosts = [
|
|
"google.com", "www.google.com",
|
|
"bing.com", "www.bing.com",
|
|
"duckduckgo.com", "www.duckduckgo.com",
|
|
"apple.com", "www.apple.com",
|
|
"github.com", "www.github.com"
|
|
]
|
|
|
|
if excludedHosts.contains(host) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// MARK: - Wrapper Unwrapping
|
|
|
|
/// Resolve a `yattee://open?url={encoded_url}` wrapper (share extension) to the inner URL.
|
|
/// Returns the input unchanged for any other URL. The wrapper's query holds the full
|
|
/// original link, so timestamp parsing must run against the unwrapped URL.
|
|
///
|
|
/// The share extension encodes with `.urlQueryAllowed`, which leaves `?`, `&`, and `=`
|
|
/// intact - URLComponents would split the inner URL's own query into separate wrapper
|
|
/// items (losing e.g. `&t=120`), so take the raw remainder after `?url=` instead.
|
|
func unwrapped(_ url: URL) -> URL {
|
|
guard url.scheme?.lowercased() == "yattee", url.host == "open",
|
|
let range = url.absoluteString.range(of: "?url=") else {
|
|
return url
|
|
}
|
|
let raw = String(url.absoluteString[range.upperBound...])
|
|
guard let decoded = raw.removingPercentEncoding,
|
|
let innerURL = URL(string: decoded) else {
|
|
return url
|
|
}
|
|
// Unwrap once more in case the wrapper was itself wrapped
|
|
if innerURL.scheme?.lowercased() == "yattee", innerURL.host == "open", innerURL != url {
|
|
return unwrapped(innerURL)
|
|
}
|
|
return innerURL
|
|
}
|
|
|
|
// MARK: - Timestamp Parsing
|
|
|
|
/// Extract a timestamp (seconds) from a URL's query, supporting `t`, `time`, and `start`.
|
|
/// Accepts plain seconds (`90`, `90.5`), YouTube-style `90s`, and `1h2m3s` / `2m30s` forms.
|
|
func parseTimestamp(_ url: URL) -> TimeInterval? {
|
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
|
let items = components.queryItems else {
|
|
return nil
|
|
}
|
|
for name in ["t", "time", "start"] {
|
|
if let raw = items.first(where: { $0.name == name })?.value,
|
|
let parsed = Self.parseTimestampValue(raw) {
|
|
return parsed
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Parse a single timestamp string. Returns nil if unparseable or zero-length.
|
|
static func parseTimestampValue(_ raw: String) -> TimeInterval? {
|
|
let trimmed = raw.trimmingCharacters(in: .whitespaces)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
|
|
// Plain numeric value (e.g. "90", "90.5"). Reject non-finite values
|
|
// ("inf"/"infinity"/"nan") that `TimeInterval(_:)` accepts — a `?t=inf`
|
|
// link would otherwise seek the player to `Double.infinity`.
|
|
if let value = TimeInterval(trimmed) {
|
|
return (value.isFinite && value >= 0) ? value : nil
|
|
}
|
|
|
|
// Compound form like "1h2m3s", "2m30s", "90s"
|
|
var total: TimeInterval = 0
|
|
var current = ""
|
|
var matched = false
|
|
for ch in trimmed {
|
|
if ch.isNumber || ch == "." {
|
|
current.append(ch)
|
|
} else {
|
|
guard let value = TimeInterval(current) else { return nil }
|
|
let unit: TimeInterval
|
|
switch ch {
|
|
case "h", "H": unit = 3600
|
|
case "m", "M": unit = 60
|
|
case "s", "S": unit = 1
|
|
default: return nil
|
|
}
|
|
total += value * unit
|
|
current = ""
|
|
matched = true
|
|
}
|
|
}
|
|
// Trailing digits without a unit suffix (e.g. "1m30") — treat as seconds.
|
|
if !current.isEmpty, let value = TimeInterval(current) {
|
|
total += value
|
|
matched = true
|
|
}
|
|
return matched ? total : nil
|
|
}
|
|
|
|
// MARK: - Custom Scheme
|
|
|
|
/// Parse yattee:// scheme URLs.
|
|
private func parseCustomScheme(_ url: URL) -> NavigationDestination? {
|
|
guard let host = url.host?.lowercased() else { return parseLegacyScheme(url) }
|
|
|
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
|
|
switch host {
|
|
case "video":
|
|
// yattee://video/{videoId}?source={source}&instance={url}
|
|
let videoID = url.lastPathComponent
|
|
guard !videoID.isEmpty else { return nil }
|
|
|
|
let sourceParam = components?.queryItems?.first(where: { $0.name == "source" })?.value
|
|
|
|
if sourceParam == "peertube",
|
|
let instanceStr = components?.queryItems?.first(where: { $0.name == "instance" })?.value,
|
|
let instanceURL = URL(string: instanceStr) {
|
|
return .video(.id(.federated(videoID, instance: instanceURL, uuid: nil)))
|
|
}
|
|
|
|
return .video(.id(.global(videoID)))
|
|
|
|
case "channel":
|
|
// yattee://channel/{channelId}?source={source}&instance={url}
|
|
let channelID = url.lastPathComponent
|
|
guard !channelID.isEmpty else { return nil }
|
|
|
|
let sourceParam = components?.queryItems?.first(where: { $0.name == "source" })?.value
|
|
let source: ContentSource
|
|
if sourceParam == "peertube",
|
|
let instanceStr = components?.queryItems?.first(where: { $0.name == "instance" })?.value,
|
|
let instanceURL = URL(string: instanceStr) {
|
|
source = .federated(provider: ContentSource.peertubeProvider, instance: instanceURL)
|
|
} else {
|
|
source = .global(provider: ContentSource.youtubeProvider)
|
|
}
|
|
|
|
return .channel(channelID, source)
|
|
|
|
case "playlist":
|
|
// yattee://playlist/{playlistId}?source={source}&instance={url}
|
|
let playlistID = url.lastPathComponent
|
|
// yattee://playlist?list=… is the 1.x form
|
|
guard !playlistID.isEmpty, playlistID != "/" else { return parseLegacyScheme(url) }
|
|
|
|
let sourceParam = components?.queryItems?.first(where: { $0.name == "source" })?.value
|
|
let source: ContentSource
|
|
if sourceParam == "peertube",
|
|
let instanceStr = components?.queryItems?.first(where: { $0.name == "instance" })?.value,
|
|
let instanceURL = URL(string: instanceStr) {
|
|
source = .federated(provider: ContentSource.peertubeProvider, instance: instanceURL)
|
|
} else {
|
|
source = .global(provider: ContentSource.youtubeProvider)
|
|
}
|
|
return .playlist(.remote(PlaylistID(source: source, playlistID: playlistID), instance: nil))
|
|
|
|
case "search":
|
|
// yattee://search?q={query}
|
|
guard let query = components?.queryItems?.first(where: { $0.name == "q" })?.value,
|
|
!query.isEmpty else {
|
|
return nil
|
|
}
|
|
return .search(query)
|
|
|
|
case "playlists":
|
|
// yattee://playlists
|
|
return .playlists
|
|
|
|
case "bookmarks":
|
|
// yattee://bookmarks
|
|
return .bookmarks
|
|
|
|
case "history":
|
|
// yattee://history
|
|
return .history
|
|
|
|
case "downloads":
|
|
// yattee://downloads
|
|
return .downloads
|
|
|
|
case "channels":
|
|
// yattee://channels (manage subscribed channels)
|
|
return .manageChannels
|
|
|
|
case "subscriptions":
|
|
// yattee://subscriptions
|
|
return .subscriptionsFeed
|
|
|
|
case "continue-watching":
|
|
// yattee://continue-watching
|
|
return .continueWatching
|
|
|
|
case "settings":
|
|
// yattee://settings
|
|
return .settings
|
|
|
|
case "open":
|
|
// yattee://open?url={encoded_url} - from share extension. `route()` unwraps
|
|
// well-formed wrappers before getting here; this is a wrapper with no
|
|
// usable inner URL.
|
|
return nil
|
|
|
|
default:
|
|
// Action hosts (yattee://play, yattee://player/…) are LinkActionParser's
|
|
if LinkActionParser.hosts.contains(host) { return nil }
|
|
return parseLegacyScheme(url)
|
|
}
|
|
}
|
|
|
|
/// Yattee 1.x accepted any web link with its scheme swapped for `yattee://`
|
|
/// (`yattee://youtube.com/watch?v=…`, `yattee://https://youtu.be/…`) and bare
|
|
/// paths (`yattee://watch?v=…`). Shortcuts and automations built for 1.x still
|
|
/// send these, so rewrite them to https and route them as web links.
|
|
private func parseLegacyScheme(_ url: URL) -> NavigationDestination? {
|
|
let absolute = url.absoluteString
|
|
guard let separator = absolute.range(of: "://") else { return nil }
|
|
var remainder = String(absolute[separator.upperBound...])
|
|
|
|
// yattee://https://… — the original URL was appended whole
|
|
if remainder.lowercased().hasPrefix("https://") || remainder.lowercased().hasPrefix("http://") {
|
|
return routeLegacyWebURL(remainder)
|
|
}
|
|
// yattee://https//… — some URL encoders drop the colon
|
|
for prefix in ["https//", "http//"] where remainder.lowercased().hasPrefix(prefix) {
|
|
remainder = String(remainder.dropFirst(prefix.count))
|
|
}
|
|
|
|
// yattee://watch?v=…, yattee://shorts/…, yattee://playlist?list=… — bare YouTube paths
|
|
let host = remainder.prefix { $0 != "/" && $0 != "?" && $0 != "#" }
|
|
if !host.contains(".") {
|
|
return routeLegacyWebURL("https://www.youtube.com/\(remainder)")
|
|
}
|
|
return routeLegacyWebURL("https://\(remainder)")
|
|
}
|
|
|
|
/// Only accept rewritten legacy links that resolve to something Yattee handles
|
|
/// natively — never the yt-dlp catch-all, so `yattee://anything` stays a no-op.
|
|
/// PeerTube-shaped paths (`yattee://host.tld/w/{id}`) pass too, as they do as web links.
|
|
private func routeLegacyWebURL(_ string: String) -> NavigationDestination? {
|
|
guard let webURL = URL(string: string), isHTTPURL(webURL) else { return nil }
|
|
return routeConfidently(webURL)
|
|
}
|
|
|
|
// MARK: - YouTube URL Parsing
|
|
|
|
/// Parse YouTube URLs and extract video ID.
|
|
private func parseYouTubeURL(_ url: URL) -> String? {
|
|
let host = url.host?.lowercased() ?? ""
|
|
|
|
// youtube.com/watch?v=VIDEO_ID
|
|
if host.contains("youtube.com") {
|
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
if let videoID = components?.queryItems?.first(where: { $0.name == "v" })?.value {
|
|
return videoID
|
|
}
|
|
|
|
// youtube.com/shorts/VIDEO_ID
|
|
if url.pathComponents.contains("shorts"),
|
|
let index = url.pathComponents.firstIndex(of: "shorts"),
|
|
url.pathComponents.count > index + 1 {
|
|
return url.pathComponents[index + 1]
|
|
}
|
|
|
|
// youtube.com/embed/VIDEO_ID
|
|
if url.pathComponents.contains("embed"),
|
|
let index = url.pathComponents.firstIndex(of: "embed"),
|
|
url.pathComponents.count > index + 1 {
|
|
return url.pathComponents[index + 1]
|
|
}
|
|
|
|
// youtube.com/live/VIDEO_ID
|
|
if url.pathComponents.contains("live"),
|
|
let index = url.pathComponents.firstIndex(of: "live"),
|
|
url.pathComponents.count > index + 1 {
|
|
return url.pathComponents[index + 1]
|
|
}
|
|
}
|
|
|
|
// youtu.be/VIDEO_ID
|
|
if host == "youtu.be" {
|
|
let videoID = url.lastPathComponent
|
|
if !videoID.isEmpty && videoID != "/" {
|
|
return videoID
|
|
}
|
|
}
|
|
|
|
// m.youtube.com
|
|
if host == "m.youtube.com" {
|
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
return components?.queryItems?.first(where: { $0.name == "v" })?.value
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MARK: - PeerTube URL Parsing
|
|
|
|
/// Parse PeerTube URLs and extract instance URL and video ID.
|
|
private func parsePeerTubeURL(_ url: URL) -> (URL, String)? {
|
|
// PeerTube URLs are typically:
|
|
// https://instance.tld/w/VIDEO_ID
|
|
// https://instance.tld/videos/watch/VIDEO_ID
|
|
|
|
guard let host = url.host,
|
|
let scheme = url.scheme else {
|
|
return nil
|
|
}
|
|
|
|
// Skip known non-PeerTube hosts
|
|
let nonPeerTubeHosts = [
|
|
"youtube.com", "www.youtube.com", "m.youtube.com",
|
|
"youtu.be", "music.youtube.com",
|
|
"vimeo.com", "www.vimeo.com",
|
|
"dailymotion.com", "www.dailymotion.com"
|
|
]
|
|
|
|
if nonPeerTubeHosts.contains(host) {
|
|
return nil
|
|
}
|
|
|
|
let pathComponents = url.pathComponents
|
|
|
|
// /w/VIDEO_ID or /videos/watch/VIDEO_ID
|
|
if pathComponents.contains("w") || pathComponents.contains("videos") {
|
|
var videoID: String?
|
|
|
|
if let wIndex = pathComponents.firstIndex(of: "w"),
|
|
pathComponents.count > wIndex + 1 {
|
|
videoID = pathComponents[wIndex + 1]
|
|
} else if let watchIndex = pathComponents.firstIndex(of: "watch"),
|
|
pathComponents.count > watchIndex + 1 {
|
|
videoID = pathComponents[watchIndex + 1]
|
|
}
|
|
|
|
if let videoID, !videoID.isEmpty {
|
|
let instanceURL = URL(string: "\(scheme)://\(host)")!
|
|
return (instanceURL, videoID)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Parse PeerTube playlist URLs: `/w/p/{id}` and `/video-playlists/{id}`.
|
|
private func parsePeerTubePlaylistURL(_ url: URL) -> (URL, String)? {
|
|
guard let host = url.host, let scheme = url.scheme, isHTTPURL(url) else { return nil }
|
|
let path = url.pathComponents
|
|
|
|
var playlistID: String?
|
|
if path.count >= 4, path[1] == "w", path[2] == "p" {
|
|
playlistID = path[3]
|
|
} else if path.count >= 3, path[1] == "video-playlists" {
|
|
playlistID = path[2]
|
|
}
|
|
|
|
guard let playlistID, !playlistID.isEmpty,
|
|
let instanceURL = URL(string: "\(scheme)://\(host)") else { return nil }
|
|
return (instanceURL, playlistID)
|
|
}
|
|
|
|
// MARK: - Channel URL Parsing
|
|
|
|
/// Parse YouTube channel URLs.
|
|
func parseYouTubeChannelURL(_ url: URL) -> String? {
|
|
let host = url.host?.lowercased() ?? ""
|
|
|
|
guard host.contains("youtube.com") else { return nil }
|
|
|
|
let pathComponents = url.pathComponents
|
|
|
|
// youtube.com/channel/CHANNEL_ID
|
|
if let channelIndex = pathComponents.firstIndex(of: "channel"),
|
|
pathComponents.count > channelIndex + 1 {
|
|
return pathComponents[channelIndex + 1]
|
|
}
|
|
|
|
// youtube.com/@HANDLE
|
|
if let component = pathComponents.first(where: { $0.hasPrefix("@") }) {
|
|
return component
|
|
}
|
|
|
|
// youtube.com/c/CUSTOM_NAME
|
|
if let cIndex = pathComponents.firstIndex(of: "c"),
|
|
pathComponents.count > cIndex + 1 {
|
|
return pathComponents[cIndex + 1]
|
|
}
|
|
|
|
// youtube.com/user/USERNAME
|
|
if let userIndex = pathComponents.firstIndex(of: "user"),
|
|
pathComponents.count > userIndex + 1 {
|
|
return pathComponents[userIndex + 1]
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Playlist URL Parsing
|
|
|
|
/// Parse YouTube playlist URLs and extract playlist ID.
|
|
private func parseYouTubePlaylistURL(_ url: URL) -> String? {
|
|
let host = url.host?.lowercased() ?? ""
|
|
|
|
guard host.contains("youtube.com") else { return nil }
|
|
|
|
// youtube.com/playlist?list=PLAYLIST_ID
|
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
if let listParam = components?.queryItems?.first(where: { $0.name == "list" })?.value {
|
|
// Only return if this is primarily a playlist URL (path is /playlist)
|
|
// or if there's no video ID (pure playlist link)
|
|
let isPlaylistPath = url.pathComponents.contains("playlist")
|
|
let hasVideoID = components?.queryItems?.first(where: { $0.name == "v" })?.value != nil
|
|
|
|
// Return playlist ID only if it's a playlist page or watch page without video ID
|
|
if isPlaylistPath || !hasVideoID {
|
|
return listParam
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|