Files
yattee/Yattee/Services/Navigation/URLRouter.swift
Arkadiusz Fal 5ae07574cb Add URL scheme actions: play, queue, download, bookmark, subscribe, player controls
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.
2026-10-05 23:58:43 +02:00

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