Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 33 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 11 additions & 1 deletion Classes/MetadataImporters/MetadataImporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
}
}
Expand Down
64 changes: 64 additions & 0 deletions Classes/Plugins/MetadataPluginLoader.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
81 changes: 81 additions & 0 deletions Classes/Plugins/PluginMetadataService.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
59 changes: 59 additions & 0 deletions Classes/Plugins/SublerMetadataPlugin.swift
Original file line number Diff line number Diff line change
@@ -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]
}
12 changes: 12 additions & 0 deletions Subler.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -215,6 +218,9 @@
A9067A0B1F3CA4E6008011DC /* MetadataPrefsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MetadataPrefsViewController.swift; path = Classes/MetadataPrefsViewController.swift; sourceTree = "<group>"; };
A90B094B235614D200F1B191 /* AppleTV.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppleTV.swift; path = Classes/MetadataImporters/AppleTV.swift; sourceTree = "<group>"; };
A90BC5E21F3A139700F9F3F3 /* MetadataResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MetadataResult.swift; path = Classes/MetadataImporters/MetadataResult.swift; sourceTree = "<group>"; };
BB77DDD7A234A42B74E027A1 /* SublerMetadataPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SublerMetadataPlugin.swift; path = Classes/Plugins/SublerMetadataPlugin.swift; sourceTree = "<group>"; };
294D32E71106886D9BB9DFE5 /* MetadataPluginLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MetadataPluginLoader.swift; path = Classes/Plugins/MetadataPluginLoader.swift; sourceTree = "<group>"; };
1F4DBCB811E1AEDD1642E30D /* PluginMetadataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PluginMetadataService.swift; path = Classes/Plugins/PluginMetadataService.swift; sourceTree = "<group>"; };
A90C3E0A1FD16AF200F64532 /* TokensViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TokensViewController.swift; path = Classes/TokensViewController.swift; sourceTree = "<group>"; };
A90CD5C11FD177230070A067 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/TokensViewController.xib; sourceTree = "<group>"; };
A90D327C2C4931A700BC7A48 /* DocumentToolbarDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DocumentToolbarDelegate.swift; path = Classes/DocumentToolbarDelegate.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down