Resolve URL shorteners and prompt for ambiguous description links

Tapping bit.ly/tinyurl/t.co/etc. in a description or comment previously
opened Safari even when the destination was a playable YouTube URL.
Added an opt-in "Resolve Short Links" toggle under YouTube Enhancements
(off by default) that follows the redirect on tap: if the target is a
YouTube/PeerTube/direct-media URL, open it in-app; otherwise prompt the
user before falling back to yt-dlp extraction or the browser.

Also added a confirmation dialog for non-shortener links that only
matched the loose .externalVideo yt-dlp fallback, so arbitrary web
pages in descriptions no longer silently kick off extraction.

Prompts live on NavigationCoordinator and are dual-hosted by YatteeApp
and ExpandedPlayerSheet so they remain visible whether or not the
expanded player is covering the main view.
This commit is contained in:
Arkadiusz Fal
2026-04-23 07:29:57 +02:00
parent d38b781858
commit 5a839da1bd
13 changed files with 511 additions and 2 deletions

View File

@@ -11,4 +11,15 @@ extension Notification.Name {
static let showSettings = Notification.Name("showSettings")
static let showOpenLinkSheet = Notification.Name("showOpenLinkSheet")
static let openDescriptionLink = Notification.Name("openDescriptionLink")
/// Posted when a URL shortener (bit.ly, etc.) has been resolved to an
/// ambiguous destination the app isn't certain it can play it, so the
/// user is prompted whether to try opening it in Yattee or in the browser.
/// `object` is the resolved `URL`.
static let promptResolvedShortLink = Notification.Name("promptResolvedShortLink")
/// Posted when a tapped link is not confidently a video (no YouTube /
/// PeerTube / direct-media match) but could potentially be extracted via
/// the Yattee server / yt-dlp. User is prompted whether to try extracting
/// or open it in the system browser instead.
/// `object` is the candidate `URL`.
static let promptAmbiguousExternalLink = Notification.Name("promptAmbiguousExternalLink")
}

View File

@@ -41,6 +41,9 @@ enum SettingsKey: String, CaseIterable {
case deArrowAPIURL
case deArrowThumbnailAPIURL
// Short link resolution
case resolveShortLinksEnabled
// Platform-specific
case macPlayerMode
case playerSheetAutoResize

View File

@@ -87,4 +87,21 @@ extension SettingsManager {
set(newValue, for: .deArrowThumbnailAPIURL)
}
}
// MARK: - Short Link Resolution
/// When enabled, taps on known URL shorteners (bit.ly, tinyurl, t.co, ) in
/// descriptions and comments follow the redirect and, if the destination is a
/// supported YouTube/PeerTube URL, open it in-app. Off by default because it
/// performs a network request to the shortener host on tap.
var resolveShortLinksEnabled: Bool {
get {
if let cached = _resolveShortLinksEnabled { return cached }
return bool(for: .resolveShortLinksEnabled, default: false)
}
set {
_resolveShortLinksEnabled = newValue
set(newValue, for: .resolveShortLinksEnabled)
}
}
}

View File

@@ -54,6 +54,9 @@ final class SettingsManager {
var _deArrowAPIURL: String?
var _deArrowThumbnailAPIURL: String?
// Short link resolution
var _resolveShortLinksEnabled: Bool?
// User Agent
var _customUserAgent: String?
var _randomizeUserAgentPerRequest: Bool?
@@ -419,6 +422,7 @@ final class SettingsManager {
_deArrowReplaceThumbnails = nil
_deArrowAPIURL = nil
_deArrowThumbnailAPIURL = nil
_resolveShortLinksEnabled = nil
_customUserAgent = nil
_randomizeUserAgentPerRequest = nil
_feedCacheValidityMinutes = nil

View File

@@ -139,6 +139,46 @@
},
"1.2M subscribers" : {
},
"alert.ambiguousLink.title" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Open Link"
}
}
}
},
"alert.ambiguousLink.message %@" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%@"
}
}
}
},
"alert.ambiguousLink.tryInYattee" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Try in Yattee"
}
}
}
},
"alert.ambiguousLink.openInBrowser" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Open in Browser"
}
}
}
},
"alert.openVideo.message %@" : {
"localizations" : {
@@ -160,6 +200,46 @@
}
}
},
"alert.resolvedShortLink.title" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Open Link"
}
}
}
},
"alert.resolvedShortLink.message %@" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "This short link expands to:\n%@"
}
}
}
},
"alert.resolvedShortLink.openInYattee" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Try in Yattee"
}
}
}
},
"alert.resolvedShortLink.openInBrowser" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Open in Browser"
}
}
}
},
"batchDownload.complete.allSkipped.subtitle" : {
"comment" : "Toast subtitle when all videos were already downloaded",
"localizations" : {
@@ -14418,6 +14498,26 @@
}
}
},
"settings.youtubeEnhancements.resolveShortLinks.footer" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "When you tap a bit.ly, tinyurl, t.co, or similar short link in a description or comment, Yattee follows the redirect and opens the destination in-app if it's a supported video, channel, or playlist. Sends a network request to the shortener service."
}
}
}
},
"settings.resolveShortLinks.title" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Resolve Short Links"
}
}
}
},
"settings.youtubeEnhancements.title" : {
"localizations" : {
"en" : {

View File

@@ -72,6 +72,16 @@ final class NavigationCoordinator {
/// when the player is expanded, so the sheet always sits above the visible layer.
var descriptionLinkQueueSheetVideo: Video?
/// Resolved short-link URL awaiting user confirmation ("Try in Yattee" vs
/// "Open in Browser"). Dual-hosted like `descriptionLinkQueueSheetVideo`
/// so the dialog is visible whether or not the expanded player is covering
/// the main app view.
var resolvedShortLinkPrompt: URL?
/// Candidate URL that isn't confidently a video (loose `.externalVideo`
/// match) awaiting user confirmation before yt-dlp extraction. Dual-hosted.
var ambiguousExternalLinkPrompt: URL?
/// Whether the mini player playlist sheet is showing.
var isMiniPlayerPlaylistSheetPresented = false

View File

@@ -12,6 +12,18 @@ 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? {
// Try custom scheme first

View File

@@ -0,0 +1,123 @@
//
// URLShortenerResolver.swift
// Yattee
//
// Best-effort resolver for known URL shortener services (bit.ly, tinyurl, t.co, ).
// Used to rescue taps on short links in video descriptions and comments: if the
// redirect target is a URL that `URLRouter` can handle, we open it in-app instead
// of bouncing out to Safari.
//
// Off-by-default feature wired via `SettingsManager.resolveShortLinksEnabled`.
//
import Foundation
enum URLShortenerResolver {
/// Hosts whose URLs we try to resolve. Kept deliberately narrow so we don't
/// fire spurious network requests against arbitrary hosts the user taps.
/// `youtu.be` is intentionally excluded `URLRouter` already handles it
/// directly without a network round-trip.
static let knownHosts: Set<String> = [
"bit.ly",
"tinyurl.com",
"t.co",
"goo.gl",
"ow.ly",
"buff.ly",
"is.gd",
"rebrand.ly",
"shorturl.at",
"cutt.ly",
"lnkd.in",
"tiny.cc",
"rb.gy"
]
/// Returns true if `url` is hosted on a known shortener service.
static func isShortener(_ url: URL) -> Bool {
guard let host = url.host?.lowercased() else { return false }
let normalizedHost = host.hasPrefix("www.") ? String(host.dropFirst(4)) : host
return knownHosts.contains(normalizedHost)
}
/// Resolves a shortener URL by following redirects. Returns `nil` on any
/// error (network failure, timeout, non-HTTP response).
///
/// Internally uses `HEAD` first; if the shortener responds 405 Method Not
/// Allowed (some do), falls back to a single `GET`. Results are cached for
/// the app lifetime to make repeat taps instant.
static func resolve(_ url: URL) async -> URL? {
if let cached = await cache.get(url) {
return cached
}
let resolved = await performResolve(url)
if let resolved {
await cache.set(url, resolved: resolved)
}
return resolved
}
// MARK: - Implementation
private static func performResolve(_ url: URL) async -> URL? {
// Try HEAD first cheapest. Fall back to GET if anything throws or
// if HEAD doesn't yield a different URL (some servers reject HEAD with
// a connection reset before following redirects).
if let viaHead = try? await request(url, method: "HEAD"), viaHead != url {
return viaHead
}
return try? await request(url, method: "GET")
}
private static func request(_ url: URL, method: String) async throws -> URL? {
var request = URLRequest(url: url, timeoutInterval: 5)
request.httpMethod = method
// A plain browser-ish UA avoids bot-blocking on a couple of services.
request.setValue(
"Mozilla/5.0 (compatible; Yattee URL resolver)",
forHTTPHeaderField: "User-Agent"
)
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 5
config.timeoutIntervalForResource = 5
config.httpCookieStorage = nil
config.urlCache = nil
let session = URLSession(configuration: config)
defer { session.finishTasksAndInvalidate() }
let (_, response) = try await session.data(for: request)
// URLSession follows redirects by default, so `response.url` is the final URL.
// We accept any status code here even a 4xx/5xx final response is fine
// because we only care about *where* the redirect chain landed, not whether
// that destination is currently serving content.
guard let finalURL = response.url, finalURL != url else { return nil }
return finalURL
}
// Bounded in-memory cache. 128 entries is plenty tapping the same link
// repeatedly in a session is common; cross-session persistence isn't needed.
private static let cache = ResolveCache(limit: 128)
private actor ResolveCache {
private var storage: [URL: URL] = [:]
private var order: [URL] = []
private let limit: Int
init(limit: Int) { self.limit = limit }
func get(_ url: URL) -> URL? { storage[url] }
func set(_ url: URL, resolved: URL) {
if storage[url] == nil {
order.append(url)
if order.count > limit, let oldest = order.first {
order.removeFirst()
storage[oldest] = nil
}
}
storage[url] = resolved
}
}
}

View File

@@ -7,6 +7,12 @@
import Foundation
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
#if canImport(AppKit)
import AppKit
#endif
// MARK: - Description Text Utilities
@@ -87,12 +93,40 @@ enum DescriptionText {
// MARK: - OpenURL Action for Seeking
/// Opens `url` in the user's default system browser (Safari on iOS/tvOS,
/// default browser on macOS). Used as the fallback when short-link resolution
/// fails or the destination isn't a URL the app can handle.
@MainActor
private func openInSystemBrowser(_ url: URL) {
#if canImport(UIKit) && !os(watchOS)
UIApplication.shared.open(url)
#elseif canImport(AppKit)
NSWorkspace.shared.open(url)
#endif
}
extension View {
/// Adds a URL handler that intercepts timestamp links (seeks the player) and
/// known content URLs YouTube/PeerTube video/channel/playlist links and external
/// video URLs so they open in-app instead of the browser.
///
/// When the user has enabled "Resolve Short Links" in YouTube Enhancements,
/// taps on known URL shorteners (bit.ly, tinyurl, t.co, ) whose hosts aren't
/// themselves routable are resolved asynchronously: if the redirect target is
/// a supported URL, it's opened in-app; otherwise we fall back to the system
/// browser with the original URL.
func handleTimestampLinks(using playerService: PlayerService?) -> some View {
self.environment(\.openURL, OpenURLAction { url in
self.modifier(HandleTimestampLinksModifier(playerService: playerService))
}
}
private struct HandleTimestampLinksModifier: ViewModifier {
let playerService: PlayerService?
@Environment(\.appEnvironment) private var appEnvironment
func body(content: Content) -> some View {
let resolveShortLinks = appEnvironment?.settingsManager.resolveShortLinksEnabled ?? false
return content.environment(\.openURL, OpenURLAction { url in
if let seconds = DescriptionText.seekSeconds(from: url) {
Task {
await playerService?.seek(to: TimeInterval(seconds))
@@ -100,11 +134,47 @@ extension View {
return .handled
}
if URLRouter().route(url) != nil {
let router = URLRouter()
// 1. Definitely-playable URLs (YouTube / PeerTube / direct media /
// custom scheme) open in-app without any prompt.
if router.routeConfidently(url) != nil {
NotificationCenter.default.post(name: .openDescriptionLink, object: url)
return .handled
}
// 2. URL shorteners resolve first, *then* decide. This has to run
// before the loose `route()` check below, because `route()` would
// otherwise match bit.ly/t.co/etc. as `.externalVideo` and send
// the *shortener* URL itself to yt-dlp.
if resolveShortLinks && URLShortenerResolver.isShortener(url) {
Task { @MainActor in
guard let resolved = await URLShortenerResolver.resolve(url) else {
openInSystemBrowser(url)
return
}
if router.routeConfidently(resolved) != nil {
NotificationCenter.default.post(name: .openDescriptionLink, object: resolved)
} else {
// Ambiguous destination (e.g. a news article). Let the user
// decide whether to try opening in Yattee (falls back to
// yt-dlp extraction) or in the system browser.
NotificationCenter.default.post(name: .promptResolvedShortLink, object: resolved)
}
}
return .handled
}
// 3. Non-shortener URLs that only match via the loose `.externalVideo`
// fallback (e.g. vimeo.com/, news articles, any http(s)): we
// can't tell whether yt-dlp will successfully extract, so ask
// the user whether to try extracting or open in the browser.
if router.routeConfidently(url) == nil, router.route(url) != nil {
NotificationCenter.default.post(name: .promptAmbiguousExternalLink, object: url)
return .handled
}
return .systemAction
})
}

View File

@@ -0,0 +1,116 @@
//
// ResolvedLinkPromptsModifier.swift
// Yattee
//
// Hosts the two confirmation dialogs used when a tapped description/comment
// link needs user approval:
//
// - `resolvedShortLinkPrompt` a bit.ly/t.co/ shortener resolved to a URL
// that isn't confidently a playable video.
// - `ambiguousExternalLinkPrompt` a non-shortener URL that only matches the
// loose `.externalVideo` yt-dlp fallback (e.g. an arbitrary webpage).
//
// The state lives on `NavigationCoordinator` so both the root app view and
// `ExpandedPlayerSheet` can host the dialogs. Only whichever host is currently
// on top presents the dialog (`shouldHost`), so the dialog is visible whether
// the expanded player is covering the main view or not matching the
// `descriptionLinkQueueSheetVideo` pattern.
//
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
#if canImport(AppKit)
import AppKit
#endif
struct ResolvedLinkPromptsModifier: ViewModifier {
/// Whether this host should actually present the dialogs right now. Used
/// so YatteeApp (root) only presents when the expanded player isn't up,
/// and ExpandedPlayerSheet only presents when it *is* up.
let shouldHost: Bool
/// Passed in explicitly (rather than read from `@Environment`) because
/// YatteeApp applies this modifier *outside* its `.appEnvironment()`
/// injection, where the environment value isn't set yet. Nil-safe so
/// ExpandedPlayerSheet (which holds an optional) can pass through.
let appEnvironment: AppEnvironment?
func body(content: Content) -> some View {
content
.confirmationDialog(
String(localized: "alert.resolvedShortLink.title"),
isPresented: shortLinkBinding,
titleVisibility: .visible,
presenting: shouldHost ? appEnvironment?.navigationCoordinator.resolvedShortLinkPrompt : nil
) { url in
Button(String(localized: "alert.resolvedShortLink.openInYattee")) {
NotificationCenter.default.post(name: .openDescriptionLink, object: url)
}
Button(String(localized: "alert.resolvedShortLink.openInBrowser")) {
openInSystemBrowser(url)
}
Button(String(localized: "common.cancel"), role: .cancel) {}
} message: { url in
Text(String(localized: "alert.resolvedShortLink.message \(url.absoluteString)"))
}
.confirmationDialog(
String(localized: "alert.ambiguousLink.title"),
isPresented: ambiguousBinding,
titleVisibility: .visible,
presenting: shouldHost ? appEnvironment?.navigationCoordinator.ambiguousExternalLinkPrompt : nil
) { url in
Button(String(localized: "alert.ambiguousLink.tryInYattee")) {
NotificationCenter.default.post(name: .openDescriptionLink, object: url)
}
Button(String(localized: "alert.ambiguousLink.openInBrowser")) {
openInSystemBrowser(url)
}
Button(String(localized: "common.cancel"), role: .cancel) {}
} message: { url in
Text(String(localized: "alert.ambiguousLink.message \(url.absoluteString)"))
}
}
private var shortLinkBinding: Binding<Bool> {
Binding(
get: { shouldHost && appEnvironment?.navigationCoordinator.resolvedShortLinkPrompt != nil },
set: { presented in
if !presented {
appEnvironment?.navigationCoordinator.resolvedShortLinkPrompt = nil
}
}
)
}
private var ambiguousBinding: Binding<Bool> {
Binding(
get: { shouldHost && appEnvironment?.navigationCoordinator.ambiguousExternalLinkPrompt != nil },
set: { presented in
if !presented {
appEnvironment?.navigationCoordinator.ambiguousExternalLinkPrompt = nil
}
}
)
}
@MainActor
private func openInSystemBrowser(_ url: URL) {
#if canImport(UIKit) && !os(watchOS)
UIApplication.shared.open(url)
#elseif canImport(AppKit)
NSWorkspace.shared.open(url)
#endif
}
}
extension View {
/// Apply at both root (when `!isPlayerExpanded`) and expanded-player level
/// (when `isPlayerExpanded`) to keep the confirmation dialogs visible above
/// whichever layer is showing. `appEnvironment` must be passed in rather
/// than read from the environment because the root call site applies this
/// modifier outside the `.appEnvironment()` injection point.
func resolvedLinkPrompts(shouldHost: Bool, appEnvironment: AppEnvironment?) -> some View {
modifier(ResolvedLinkPromptsModifier(shouldHost: shouldHost, appEnvironment: appEnvironment))
}
}

View File

@@ -395,6 +395,13 @@ struct ExpandedPlayerSheet: View {
.appEnvironment(appEnvironment)
}
}
// Host the resolved/ambiguous link confirmation dialogs while the
// expanded player is up so they appear above this sheet rather than
// being buried underneath it on the root app view.
.resolvedLinkPrompts(
shouldHost: (appEnvironment?.navigationCoordinator.isPlayerExpanded == true),
appEnvironment: appEnvironment
)
#if os(iOS)
.toolbar(.hidden, for: .navigationBar)
.playerStatusBarHidden(isInWideScreenLayout || !isPortraitPanelVisible)

View File

@@ -16,6 +16,7 @@ struct YouTubeEnhancementsSettingsView: View {
SponsorBlockSection(settings: settings)
ReturnYouTubeDislikeSection(settings: settings)
DeArrowSection(settings: settings)
ResolveShortLinksSection(settings: settings)
}
}
#if !os(tvOS)
@@ -93,6 +94,21 @@ private struct DeArrowSection: View {
}
}
// MARK: - Resolve Short Links Section
private struct ResolveShortLinksSection: View {
@Bindable var settings: SettingsManager
var body: some View {
SettingsFormSection(footer: "settings.youtubeEnhancements.resolveShortLinks.footer") {
Toggle(
String(localized: "settings.resolveShortLinks.title"),
isOn: $settings.resolveShortLinksEnabled
)
}
}
}
#Preview {
NavigationStack {
YouTubeEnhancementsSettingsView()

View File

@@ -51,6 +51,9 @@ struct YatteeApp: App {
#endif
@State private var showingOpenLinkSheet = false
// Ambiguous-link prompts are stored on NavigationCoordinator so they can
// be dual-hosted by the root app view (here) and the expanded player sheet.
init() {
// Configure Nuke image loading pipeline
ImageLoadingService.shared.configure()
@@ -192,6 +195,23 @@ struct YatteeApp: App {
handleDescriptionLink(url)
}
}
.onReceive(NotificationCenter.default.publisher(for: .promptResolvedShortLink)) { notification in
if let url = notification.object as? URL {
appEnvironment.navigationCoordinator.resolvedShortLinkPrompt = url
}
}
.onReceive(NotificationCenter.default.publisher(for: .promptAmbiguousExternalLink)) { notification in
if let url = notification.object as? URL {
appEnvironment.navigationCoordinator.ambiguousExternalLinkPrompt = url
}
}
// Present confirmation dialogs at root level only when the expanded
// player isn't covering it. ExpandedPlayerSheet hosts the same
// dialogs when it *is* expanded, so they always sit on top.
.resolvedLinkPrompts(
shouldHost: !appEnvironment.navigationCoordinator.isPlayerExpanded,
appEnvironment: appEnvironment
)
}
#if os(macOS)
.windowStyle(.hiddenTitleBar)