From 80dea3da4c89e31ca5ede75e53513a575c91979a Mon Sep 17 00:00:00 2001 From: Hans Hoffmeister Date: Mon, 14 Sep 2026 04:18:11 +0000 Subject: [PATCH 1/2] Add support for dynamically-loaded metadata source plugins Lets a third party add a movie metadata source to Subler without touching Subler's own source or Xcode project: they build a small, loadable .bundle implementing a Foundation-only protocol, and drop it into ~/Library/Application Support/Subler/Plugins. - SublerMetadataPlugin.swift: the protocol a plugin's principal class implements (searchMovie/loadMovieMetadata using plain String/[String: Any] dictionaries only, so a plugin project never needs to link against Subler's own types or MP42Foundation). - MetadataPluginLoader.swift: discovers and loads every .bundle in the Plugins folder, skipping (and logging) anything that fails to load or doesn't conform, so one broken plugin can never take down the rest of Subler. - PluginMetadataService.swift: adapts a loaded plugin to Subler's own MetadataService protocol, so it shows up in the Search Metadata dialog and batch queue provider popups exactly like a built-in source. With no plugin bundles installed -- the common case -- this changes nothing about Subler's behavior. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ETy3zxWgGdZPVzjN3vqbbC --- .../MetadataImporters/MetadataImporter.swift | 12 ++- Classes/Plugins/MetadataPluginLoader.swift | 64 +++++++++++++++ Classes/Plugins/PluginMetadataService.swift | 81 +++++++++++++++++++ Classes/Plugins/SublerMetadataPlugin.swift | 59 ++++++++++++++ Subler.xcodeproj/project.pbxproj | 12 +++ 5 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 Classes/Plugins/MetadataPluginLoader.swift create mode 100644 Classes/Plugins/PluginMetadataService.swift create mode 100644 Classes/Plugins/SublerMetadataPlugin.swift diff --git a/Classes/MetadataImporters/MetadataImporter.swift b/Classes/MetadataImporters/MetadataImporter.swift index 7ea01b8f..0262f534 100644 --- a/Classes/MetadataImporters/MetadataImporter.swift +++ b/Classes/MetadataImporters/MetadataImporter.swift @@ -131,7 +131,14 @@ public enum MetadataSearch { extension MetadataSearch { - public static var movieProviders: [String] { get { return [AppleTV().name, TheMovieDB().name, TheTVDB().name, iTunesStore().name] } } + /// Plugin-provided movie sources, loaded once at first use from + /// ~/Library/Application Support/Subler/Plugins -- see + /// MetadataPluginLoader and SublerMetadataPlugin. Empty when no + /// plugin bundles are installed, which is the common case; nothing + /// else here behaves any differently when it's empty. + private static let pluginServices: [PluginMetadataService] = MetadataPluginLoader.loadPlugins().map { PluginMetadataService(plugin: $0) } + + public static var movieProviders: [String] { get { return [AppleTV().name, TheMovieDB().name, TheTVDB().name, iTunesStore().name] + pluginServices.map { $0.name } } } public static var tvProviders: [String] { get { return [AppleTV().name, TheMovieDB().name, TheTVDB().name, iTunesStore().name] } } public static func service(name: String?) -> MetadataService { @@ -145,6 +152,9 @@ extension MetadataSearch { case TheTVDB().name?: return TheTVDB() default: + if let name = name, let plugin = pluginServices.first(where: { $0.name == name }) { + return plugin + } return TheMovieDB() } } diff --git a/Classes/Plugins/MetadataPluginLoader.swift b/Classes/Plugins/MetadataPluginLoader.swift new file mode 100644 index 00000000..74e4086b --- /dev/null +++ b/Classes/Plugins/MetadataPluginLoader.swift @@ -0,0 +1,64 @@ +// +// MetadataPluginLoader.swift +// Subler +// +// Discovers and loads metadata-source plugin bundles. Plugins are +// entirely optional -- with none installed, Subler behaves exactly as +// it does with only its built-in providers and JSON-configured Custom +// Sources. See SublerMetadataPlugin.swift for the contract a plugin +// implements, and PluginMetadataService.swift for how a loaded plugin +// gets wired into the rest of the app. +// + +import Foundation + +enum MetadataPluginLoader { + + /// Where Subler looks for plugin bundles. Not created automatically -- + /// a user installing a plugin creates it (or the plugin's own + /// installer does), same as QueuePreferences.destination is just a + /// folder the user picked rather than one Subler manages the + /// lifecycle of. + static var pluginsDirectory: URL? { + return FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first? + .appendingPathComponent("Subler", isDirectory: true) + .appendingPathComponent("Plugins", isDirectory: true) + } + + /// Loads every plugin bundle found in pluginsDirectory. A bundle that + /// fails to load, or whose principal class doesn't conform to + /// SublerMetadataPlugin, is logged and skipped rather than treated as + /// fatal -- one broken or incompatible plugin should never keep the + /// rest of Subler from launching normally. + static func loadPlugins() -> [SublerMetadataPlugin] { + guard let directory = pluginsDirectory else { return [] } + + let bundleURLs = (try? FileManager.default.contentsOfDirectory(at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles])) ?? [] + + return bundleURLs + .filter { $0.pathExtension == "bundle" } + .compactMap { loadPlugin(at: $0) } + } + + private static func loadPlugin(at url: URL) -> SublerMetadataPlugin? { + guard let bundle = Bundle(url: url) else { + NSLog("[MetadataPluginLoader] %@ is not a valid bundle", url.lastPathComponent) + return nil + } + guard bundle.load() else { + NSLog("[MetadataPluginLoader] Failed to load %@", url.lastPathComponent) + return nil + } + guard let principalClass = bundle.principalClass as? NSObject.Type else { + NSLog("[MetadataPluginLoader] %@ has no usable principal class", url.lastPathComponent) + return nil + } + guard let instance = principalClass.init() as? SublerMetadataPlugin else { + NSLog("[MetadataPluginLoader] %@'s principal class doesn't conform to SublerMetadataPlugin", url.lastPathComponent) + return nil + } + return instance + } +} diff --git a/Classes/Plugins/PluginMetadataService.swift b/Classes/Plugins/PluginMetadataService.swift new file mode 100644 index 00000000..46bdd332 --- /dev/null +++ b/Classes/Plugins/PluginMetadataService.swift @@ -0,0 +1,81 @@ +// +// PluginMetadataService.swift +// Subler +// +// Wraps a loaded SublerMetadataPlugin so it behaves exactly like a +// built-in MetadataService everywhere Subler uses one -- the Search +// Metadata dialog, the batch queue's provider popup, etc. Translates the +// plugin's Foundation-only dictionaries (see SublerMetadataPlugin) into +// Subler's own MetadataResult type and back. +// + +import Foundation +import MP42Foundation + +public struct PluginMetadataService: MetadataService { + + let plugin: SublerMetadataPlugin + + public var languages: [String] { return ["en"] } + public var languageType: LanguageType { return .custom } + public var defaultLanguage: String { return "en" } + public var name: String { return plugin.pluginName } + + // MARK: - Movie search + + public func search(movie: String, language: String) -> [MetadataResult] { + return plugin.searchMovie(query: movie).map { metadataResult(from: $0) } + } + + public func loadMovieMetadata(_ partialMetadata: MetadataResult, language: String) -> MetadataResult { + guard let id = partialMetadata[.serviceContentID] as? String else { return partialMetadata } + let dictionary = plugin.loadMovieMetadata(id: id) + return dictionary.isEmpty ? partialMetadata : metadataResult(from: dictionary) + } + + /// Maps the documented dictionary keys (see SublerMetadataPlugin) onto + /// the corresponding MetadataResult fields. Every key is optional -- + /// a plugin only needs to supply what it actually has. + private func metadataResult(from dictionary: [String: Any]) -> MetadataResult { + let metadata = MetadataResult() + metadata.mediaKind = .movie + + metadata[.serviceContentID] = dictionary["id"] as? String + metadata[.name] = dictionary["title"] as? String + metadata[.releaseDate] = dictionary["releaseDate"] as? String + metadata[.longDescription] = dictionary["synopsis"] as? String + metadata[.studio] = dictionary["studio"] as? String + metadata[.director] = dictionary["director"] as? String + metadata[.seriesName] = dictionary["series"] as? String + + if let cast = dictionary["cast"] as? [String], cast.isEmpty == false { + metadata[.cast] = cast.joined(separator: ", ") + } + if let genre = dictionary["genre"] as? [String], genre.isEmpty == false { + metadata[.genre] = genre.joined(separator: ", ") + } + + if let artworkURLStrings = dictionary["artworkURLs"] as? [String] { + metadata.remoteArtworks = artworkURLStrings.compactMap { string -> Artwork? in + guard let url = URL(string: string) else { return nil } + return Artwork(url: url, thumbURL: url, service: name, type: .poster, size: .standard) + } + } + + return metadata + } + + // MARK: - TV Show (not supported by plugins yet) + + public func search(tvShow: String, language: String) -> [String] { + return [] + } + + public func search(tvShow: String, language: String, season: Int?, episode: Int?) -> [MetadataResult] { + return [] + } + + public func loadTVMetadata(_ metadata: MetadataResult, language: String) -> MetadataResult { + return metadata + } +} diff --git a/Classes/Plugins/SublerMetadataPlugin.swift b/Classes/Plugins/SublerMetadataPlugin.swift new file mode 100644 index 00000000..41e5326a --- /dev/null +++ b/Classes/Plugins/SublerMetadataPlugin.swift @@ -0,0 +1,59 @@ +// +// SublerMetadataPlugin.swift +// Subler +// +// The interface a dynamically-loaded metadata-source plugin bundle must +// implement. See MetadataPluginLoader.swift for how a plugin gets found +// and loaded, and PluginMetadataService.swift for how a loaded plugin is +// wrapped so it behaves exactly like a built-in provider everywhere +// Subler uses MetadataService. +// +// This file is deliberately the ONLY thing shared between Subler's own +// source and a plugin project -- it references nothing else in Subler +// (no MetadataResult, no MP42Foundation, nothing internal), just +// Foundation types. That keeps a plugin project free to build and +// version itself entirely independently: a plugin author only ever +// needs a copy of this one small protocol, never Subler's own source +// or Xcode project. +// + +import Foundation + +/// A dynamically-loaded metadata-source plugin. A plugin is a normal +/// loadable bundle (.bundle) placed by the user in +/// ~/Library/Application Support/Subler/Plugins -- see +/// MetadataPluginLoader. The bundle's principal class (declared as +/// NSPrincipalClass in its Info.plist) must be an NSObject subclass with +/// a plain, parameterless init() that conforms to this protocol. +/// +/// Movie search only, for now -- there's no TV show equivalent yet. +@objc public protocol SublerMetadataPlugin: NSObjectProtocol { + + /// A stable, human-readable name for this source. Shown in the + /// Search Metadata dialog and batch queue provider popups exactly + /// like a built-in provider's name, so it should be short and not + /// collide with an existing provider's name. + var pluginName: String { get } + + /// Searches for a movie by title. Each result dictionary must at + /// least contain "id" (String) -- an opaque identifier this plugin + /// will recognize when it comes back via loadMovieMetadata(id:) -- + /// and "title" (String). All other keys are optional: + /// - "releaseDate": String, "yyyy-MM-dd" + /// - "synopsis": String + /// - "studio": String + /// - "director": String + /// - "series": String + /// - "cast": [String] + /// - "genre": [String] + /// - "artworkURLs": [String], each an absolute image URL + /// Any key that isn't relevant or available can simply be omitted. + func searchMovie(query: String) -> [[String: Any]] + + /// Loads full metadata for one search result, identified by the "id" + /// value that result carried. Returns a dictionary using the same + /// key set as searchMovie's results, filled in as completely as + /// possible. Return an empty dictionary if the id can no longer be + /// resolved (e.g. the page was removed). + func loadMovieMetadata(id: String) -> [String: Any] +} diff --git a/Subler.xcodeproj/project.pbxproj b/Subler.xcodeproj/project.pbxproj index 1e12c8ed..97d6f170 100644 --- a/Subler.xcodeproj/project.pbxproj +++ b/Subler.xcodeproj/project.pbxproj @@ -26,6 +26,9 @@ A90B023B10F3290A000416F3 /* PrefsWindowController.xib in Resources */ = {isa = PBXBuildFile; fileRef = A90B023910F3290A000416F3 /* PrefsWindowController.xib */; }; A90B094C235614D200F1B191 /* AppleTV.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90B094B235614D200F1B191 /* AppleTV.swift */; }; A90BC5E31F3A139700F9F3F3 /* MetadataResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90BC5E21F3A139700F9F3F3 /* MetadataResult.swift */; }; + 3792216AEDF7F5D341FEF315 /* SublerMetadataPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB77DDD7A234A42B74E027A1 /* SublerMetadataPlugin.swift */; }; + 4D362AB2056E3D8EA387A788 /* MetadataPluginLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 294D32E71106886D9BB9DFE5 /* MetadataPluginLoader.swift */; }; + D41C667D2EA4633901CB5983 /* PluginMetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F4DBCB811E1AEDD1642E30D /* PluginMetadataService.swift */; }; A90C3E0C1FD16AF200F64532 /* TokensViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90C3E0A1FD16AF200F64532 /* TokensViewController.swift */; }; A90CD5C01FD177230070A067 /* TokensViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = A90CD5C21FD177230070A067 /* TokensViewController.xib */; }; A90CD5C51FD177280070A067 /* OutputPrefsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = A90CD5C71FD177280070A067 /* OutputPrefsViewController.xib */; }; @@ -215,6 +218,9 @@ A9067A0B1F3CA4E6008011DC /* MetadataPrefsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MetadataPrefsViewController.swift; path = Classes/MetadataPrefsViewController.swift; sourceTree = ""; }; A90B094B235614D200F1B191 /* AppleTV.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppleTV.swift; path = Classes/MetadataImporters/AppleTV.swift; sourceTree = ""; }; A90BC5E21F3A139700F9F3F3 /* MetadataResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MetadataResult.swift; path = Classes/MetadataImporters/MetadataResult.swift; sourceTree = ""; }; + BB77DDD7A234A42B74E027A1 /* SublerMetadataPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SublerMetadataPlugin.swift; path = Classes/Plugins/SublerMetadataPlugin.swift; sourceTree = ""; }; + 294D32E71106886D9BB9DFE5 /* MetadataPluginLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MetadataPluginLoader.swift; path = Classes/Plugins/MetadataPluginLoader.swift; sourceTree = ""; }; + 1F4DBCB811E1AEDD1642E30D /* PluginMetadataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PluginMetadataService.swift; path = Classes/Plugins/PluginMetadataService.swift; sourceTree = ""; }; A90C3E0A1FD16AF200F64532 /* TokensViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TokensViewController.swift; path = Classes/TokensViewController.swift; sourceTree = ""; }; A90CD5C11FD177230070A067 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/TokensViewController.xib; sourceTree = ""; }; A90D327C2C4931A700BC7A48 /* DocumentToolbarDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DocumentToolbarDelegate.swift; path = Classes/DocumentToolbarDelegate.swift; sourceTree = ""; }; @@ -778,6 +784,9 @@ A9CBAB1D1F29C083001C26B3 /* MetadataImporter.swift */, A9AE70E01F2F7B3D001CF27C /* MetadataHelper.swift */, A90BC5E21F3A139700F9F3F3 /* MetadataResult.swift */, + BB77DDD7A234A42B74E027A1 /* SublerMetadataPlugin.swift */, + 294D32E71106886D9BB9DFE5 /* MetadataPluginLoader.swift */, + 1F4DBCB811E1AEDD1642E30D /* PluginMetadataService.swift */, A900CE3D1F3ACF9700CCA08E /* MetadataResultMap.swift */, A97FA41D23E9837000EE5833 /* Ratings.swift */, A9E97F131F274F1100DBA586 /* iTunesStore.swift */, @@ -983,6 +992,9 @@ A92CE72522FAFED0000962A4 /* ViewController.swift in Sources */, A9915AC31F4B293B003C57D1 /* PresetManager.swift in Sources */, A90BC5E31F3A139700F9F3F3 /* MetadataResult.swift in Sources */, + 3792216AEDF7F5D341FEF315 /* SublerMetadataPlugin.swift in Sources */, + 4D362AB2056E3D8EA387A788 /* MetadataPluginLoader.swift in Sources */, + D41C667D2EA4633901CB5983 /* PluginMetadataService.swift in Sources */, A99189CD1F2F87EC00623ED7 /* ChapterImporter.swift in Sources */, A99D23212029CD32002E7F27 /* PrefsWindow.swift in Sources */, A9915ABF1F4B26CF003C57D1 /* MetadataPreset.swift in Sources */, From 7ba768c54ee60d097abac32ffa71566e7eee0598 Mon Sep 17 00:00:00 2001 From: Hans Hoffmeister Date: Fri, 18 Sep 2026 02:07:19 +0000 Subject: [PATCH 2/2] Add a GitHub Actions build check Compile-only verification (no signing, no tests) on a macOS runner: checks out the repo with submodules, then xcodebuilds the Subler target. Runs on every push and PR to any branch, so a branch shows red/green on GitHub before anyone has to open Xcode. This is fork/CI infrastructure, not app code -- merged into every branch that should get build coverage, but deliberately kept off of main so main stays a byte-for-byte mirror of upstream for clean rebasing. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ETy3zxWgGdZPVzjN3vqbbC --- .github/workflows/build.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..3d45b785 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,33 @@ +name: Build + +# Compile-only verification, no signing/notarization/tests. Runs on every +# push and PR so a branch (main, a PR branch, a personal-build branch, +# whatever) shows red/green on GitHub before anyone has to open Xcode. +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + workflow_dispatch: + +jobs: + build: + runs-on: macos-14 + + steps: + - name: Check out repo (with submodules) + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Show Xcode version + run: xcodebuild -version + + - name: Build Subler + run: | + xcodebuild \ + -project Subler.xcodeproj \ + -target Subler \ + -configuration Debug \ + CODE_SIGNING_ALLOWED=NO \ + build