Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d029437
Add YouTube feed transformer for inline video embedding
acrollet Sep 7, 2025
eac2a30
Fix YouTube transformer tests and improve video ID validation
acrollet Sep 7, 2025
3bc4397
Integrate YouTube transformer with NetNewsWire feed processing pipeline
acrollet Sep 7, 2025
a93b41d
Fix YouTube video embedding for real RSS feed structure
acrollet Sep 7, 2025
1785fe3
Improve YouTube embed reliability and fix Error code 4
acrollet Sep 7, 2025
5529df7
Add Media RSS support and thumbnail embedding inspired by Reeder 5
acrollet Sep 7, 2025
ae6e9f8
Fix YouTube video embedding with HTTPS base URL and enhanced WKWebVie…
acrollet Sep 13, 2025
673b232
Add comprehensive debugging and update tests for YouTube transformer
acrollet Sep 13, 2025
551e8a3
Remove thumbnail and YouTube link from video embeds
acrollet Sep 13, 2025
5b3822d
Add comprehensive debugging for YouTube transformer flow
acrollet Sep 13, 2025
4efad03
Fix YouTube transformer to work on initial feed creation
acrollet Sep 13, 2025
bc4e452
Add support for YouTube Shorts URLs in video embedding
acrollet Sep 13, 2025
aec86d3
Add test to verify transformers are applied during initial feed creation
acrollet Sep 13, 2025
bb208a6
Clean up excessive debug logging to match app conventions
acrollet Sep 13, 2025
ef46ca2
Cleanup
acrollet Sep 13, 2025
a64f6b8
Update test expectations to match current YouTube transformer impleme…
acrollet Sep 13, 2025
56298fc
Cleanup
acrollet Sep 13, 2025
970c7ef
Remove claude file
acrollet Sep 13, 2025
f4cd331
Restore claude file from main
acrollet Sep 13, 2025
89fcad5
Revert unneccessary additions
acrollet Sep 13, 2025
2b9af61
Remove delegate stubs
acrollet Sep 13, 2025
cc01be5
Cleanup
acrollet Sep 13, 2025
815b2cc
Mac Detail: use bare HTTPS origin as WKWebView baseURL.
acrollet Sep 14, 2025
21a8e8a
FeedFinder: remove pre-download URL correction.
acrollet Sep 14, 2025
a73d85c
Remove planning doc
acrollet Sep 14, 2025
8e0ccf5
Update Mac/MainWindow/Detail/DetailWebViewController.swift
acrollet Sep 14, 2025
0340f5e
Update Mac/MainWindow/Detail/DetailWebViewController.swift
acrollet Sep 14, 2025
40dbff4
Update Modules/Account/Tests/AccountTests/FeedTransformerTests.swift
acrollet Sep 14, 2025
6c99c9e
Merge branch 'Ranchero-Software:main' into feature/youtube-inline-video
acrollet Sep 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Mac/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations,
SecretsManager.provider = Secrets()
AccountManager.shared = AccountManager(accountsFolder: Platform.dataSubfolder(forApplication: nil, folderName: "Accounts")!)
ArticleThemesManager.shared = ArticleThemesManager(folderPath: Platform.dataSubfolder(forApplication: nil, folderName: "Themes")!)

// Register feed transformers
FeedTransformerRegistry.shared.register(YouTubeFeedTransformer())

NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(inspectableObjectsDidChange(_:)), name: .InspectableObjectsDidChange, object: nil)
Expand Down
29 changes: 26 additions & 3 deletions Mac/MainWindow/Detail/DetailWebViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,15 @@ final class DetailWebViewController: NSViewController {
configuration.preferences = preferences
configuration.defaultWebpagePreferences.allowsContentJavaScript = AppDefaults.shared.isArticleContentJavascriptEnabled
configuration.setURLSchemeHandler(detailIconSchemeHandler, forURLScheme: ArticleRenderer.imageIconScheme)
configuration.mediaTypesRequiringUserActionForPlayback = .audio

// Enable video playback for YouTube embeds
if #available(iOS 10.0, macOS 10.12, *) {
configuration.mediaTypesRequiringUserActionForPlayback = []
}

#if os(iOS)
configuration.allowsInlineMediaPlayback = true // Enable inline video playback (iOS only)
#endif

let userContentController = WKUserContentController()
userContentController.add(self, name: MessageName.windowDidScroll)
Expand Down Expand Up @@ -268,7 +276,6 @@ extension DetailWebViewController: WKNavigationDelegate, WKUIDelegate {
}
}
}

// WKUIDelegate

func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
Expand Down Expand Up @@ -337,7 +344,23 @@ private extension DetailWebViewController {

var html = try! MacroProcessor.renderedText(withTemplate: ArticleRenderer.page.html, substitutions: substitutions)
html = ArticleRenderingSpecialCases.filterHTMLIfNeeded(baseURL: rendering.baseURL, html: html)
webView.loadHTMLString(html, baseURL: URL(string: rendering.baseURL))

// Normalize base URL to bare domain over HTTPS (no path, query, or fragment).
var finalBaseURL: URL? = nil
if var comps = URLComponents(string: rendering.baseURL) {
// Force HTTPS scheme
comps.scheme = "https"
// Strip user/password, port, query, fragment, and set path to root
comps.user = nil
comps.password = nil
comps.port = nil
comps.query = nil
comps.fragment = nil
comps.path = "/"
finalBaseURL = comps.url
}

webView.loadHTMLString(html, baseURL: finalBaseURL)
}

func fetchScrollInfo(_ completion: @escaping (ScrollInfo?) -> Void) {
Expand Down
46 changes: 46 additions & 0 deletions Modules/Account/Sources/Account/FeedTransformer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// FeedTransformer.swift
// Account
//

import Foundation
import RSParser

/// Protocol for transforming feed content to add special functionality
/// like video embedding or content enhancement.
public protocol FeedTransformer {

/// Determines if this transformer applies to the given feed URL
/// - Parameter feedURL: The feed URL to check
/// - Returns: True if this transformer should be applied to the feed
func applies(to feedURL: String) -> Bool

/// Corrects the feed URL if needed (e.g., YouTube channel page to RSS feed)
/// - Parameter feedURL: The original feed URL
/// - Returns: The corrected feed URL, or nil if no correction is needed
func correctFeedURL(_ feedURL: String) -> String?

/// Transforms the parsed feed content
/// - Parameter parsedFeed: The original parsed feed
/// - Returns: The transformed parsed feed
func transform(_ parsedFeed: ParsedFeed) -> ParsedFeed

/// Priority for applying transformers (higher numbers = higher priority)
/// Used when multiple transformers apply to the same feed
var priority: Int { get }

/// Unique identifier for this transformer type
var identifier: String { get }
}

/// Default implementations for convenience
public extension FeedTransformer {

/// Default priority is 0 (lowest)
var priority: Int { return 0 }

/// Default identifier is the class name
var identifier: String {
return String(describing: type(of: self))
}
}
89 changes: 89 additions & 0 deletions Modules/Account/Sources/Account/FeedTransformerRegistry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//
// FeedTransformerRegistry.swift
// Account
//

import Foundation
import RSParser
import RSCore

/// Registry for managing and applying feed transformers
public final class FeedTransformerRegistry {

public static let shared = FeedTransformerRegistry()

private var transformers: [FeedTransformer] = []
private let queue = DispatchQueue(label: "FeedTransformerRegistry", qos: .utility)

private init() {}

/// Registers a new feed transformer
/// - Parameter transformer: The transformer to register
public func register(_ transformer: FeedTransformer) {
queue.async {
// Remove any existing transformer with the same identifier
self.transformers.removeAll { $0.identifier == transformer.identifier }

// Add the new transformer and sort by priority (highest first)
self.transformers.append(transformer)
self.transformers.sort { $0.priority > $1.priority }
}
}

/// Unregisters a transformer by identifier
/// - Parameter identifier: The identifier of the transformer to remove
public func unregister(identifier: String) {
queue.async {
self.transformers.removeAll { $0.identifier == identifier }
}
}

/// Corrects a feed URL by applying the first applicable transformer
/// - Parameter feedURL: The original feed URL
/// - Returns: The corrected feed URL, or the original if no correction is needed
public func correctFeedURL(_ feedURL: String) -> String {
return queue.sync {
for transformer in transformers {
if transformer.applies(to: feedURL) {
if let correctedURL = transformer.correctFeedURL(feedURL) {
return correctedURL
}
}
}
return feedURL
}
}

/// Transforms a parsed feed by applying all applicable transformers
/// - Parameters:
/// - parsedFeed: The original parsed feed
/// - feedURL: The feed URL for determining applicable transformers
/// - Returns: The transformed parsed feed
public func transform(_ parsedFeed: ParsedFeed, feedURL: String) -> ParsedFeed {
return queue.sync {
var result = parsedFeed

for transformer in transformers {
if transformer.applies(to: feedURL) {
result = transformer.transform(result)
}
}

return result
}
}

/// Returns all registered transformers (for testing/debugging)
public func registeredTransformers() -> [FeedTransformer] {
return queue.sync {
return Array(transformers)
}
}

/// Clears all registered transformers
public func clearAll() {
queue.async {
self.transformers.removeAll()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,10 @@ private extension LocalAccountDelegate {
feed.editedName = editedName
container.addWebFeed(feed)

account.update(feed, with: parsedFeed, {_ in
// Apply feed transformers for initial feed creation (same as refresh)
let transformedFeed = FeedTransformerRegistry.shared.transform(parsedFeed, feedURL: feed.url)

account.update(feed, with: transformedFeed, {_ in
BatchUpdate.shared.end()
completion(.success(feed))
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,10 @@ extension LocalAccountRefresher: DownloadSessionDelegate {
return
}

account.update(feed, with: parsedFeed) { result in
// Apply feed transformers
let transformedFeed = FeedTransformerRegistry.shared.transform(parsedFeed, feedURL: feed.url)

account.update(feed, with: transformedFeed) { result in
if case .success(let articleChanges) = result {
feed.contentHash = dataHash
feed.conditionalGetInfo = conditionalGetInfo
Expand Down
Loading