mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
349 lines
12 KiB
Swift
349 lines
12 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 to a navigation destination.
|
|
func route(_ url: URL) -> NavigationDestination? {
|
|
// Try custom scheme first
|
|
if url.scheme == "yattee" {
|
|
return parseCustomScheme(url)
|
|
}
|
|
|
|
// 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
|
|
if 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 isExternalVideoURL(_ url: URL) -> Bool {
|
|
// Must be http or https
|
|
guard let scheme = url.scheme?.lowercased(),
|
|
scheme == "http" || scheme == "https" 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",
|
|
"apple.com", "www.apple.com",
|
|
"github.com", "www.github.com"
|
|
]
|
|
|
|
if excludedHosts.contains(host) {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// MARK: - Custom Scheme
|
|
|
|
/// Parse yattee:// scheme URLs.
|
|
private func parseCustomScheme(_ url: URL) -> NavigationDestination? {
|
|
guard let host = url.host else { return nil }
|
|
|
|
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}
|
|
let playlistID = url.lastPathComponent
|
|
guard !playlistID.isEmpty else { return nil }
|
|
return .playlist(.remote(PlaylistID(source: .global(provider: ContentSource.youtubeProvider), 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
|
|
if let urlParam = components?.queryItems?.first(where: { $0.name == "url" })?.value,
|
|
let decodedURL = URL(string: urlParam) {
|
|
// Route the decoded URL through normal routing
|
|
return route(decodedURL)
|
|
}
|
|
return nil
|
|
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|