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 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 */,