diff --git a/.gitignore b/.gitignore index 196e2f7..b8f330e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,8 +31,9 @@ xcuserdata timeline.xctimeline playground.xcworkspace -## Vim swap files -*.swp +## Vim swap and session files +*.sw[po] +*.vimsession # Swift Package Manager # @@ -40,6 +41,8 @@ playground.xcworkspace Packages/ .build/ Package.pins +Package.resolved +.AppleDouble # CocoaPods # diff --git a/.swift-version b/.swift-version index 8c50098..6b244dc 100644 --- a/.swift-version +++ b/.swift-version @@ -1 +1 @@ -3.1 +5.0.1 diff --git a/.travis.yml b/.travis.yml index 1f2fac8..174215e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ os: - linux - osx -osx_image: xcode8.3 +osx_image: xcode10.2 dist: trusty sudo: required language: generic @@ -11,8 +11,9 @@ language: generic #before_install: # - sudo apt-get update # - sudo apt-get install -y libimage-exiftool-perl +before_install: + - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then eval "$(curl -sL https://swiftenv.fuller.li/install.sh)"; fi install: - - eval "$(curl -sL https://gist.githubusercontent.com/kylef/5c0475ff02b7c7671d2a/raw/9f442512a46d7a2af7b850d65a7e9bd31edfb09b/swiftenv-install.sh)" + - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then swiftenv install $(cat .swift-version) | grep -qe "\(been\|already\) installed"; fi script: - - swift build -c release - - swift test + - travis_wait swift test diff --git a/Downpour.podspec b/Downpour.podspec deleted file mode 100644 index 3f093b7..0000000 --- a/Downpour.podspec +++ /dev/null @@ -1,28 +0,0 @@ -Pod::Spec.new do |s| - s.name = "Downpour" - s.version = "0.2.0" - s.summary = "Get TV & Movie info from downloaded filenames" - - s.description = <<-DESC -Downpour was built for [Fetch](http://getfetchapp.com) — a Put.io client — to parse TV & Movie information from downloaded files. It can gather the following from a raw file name: - -- TV or movie title -- Year of release -- TV season number -- TV episode number - DESC - - s.homepage = "https://github.com/steve228uk/Downpour" - # s.screenshots = "www.example.com/screenshots_1", "www.example.com/screenshots_2" - s.license = 'MIT' - s.author = { "Stephen Radford" => "steve228uk@gmail.com" } - s.source = { :git => "https://github.com/steve228uk/Downpour.git", :tag => s.version.to_s } - s.social_media_url = 'https://twitter.com/steve228uk' - - s.osx.deployment_target = '10.10' - s.ios.deployment_target = '8.0' - s.tvos.deployment_target = '9.0' - - s.source_files = 'Sources/**/*' - -end diff --git a/Package.swift b/Package.swift index 0968793..1054dce 100644 --- a/Package.swift +++ b/Package.swift @@ -1,12 +1,27 @@ -import PackageDescription - -var dependencies: [Package.Dependency] = [.Package(url: "https://github.com/Ponyboy47/PathKit.git", majorVersion: 0, minor: 8)] +// swift-tools-version:5.0 -#if os(Linux) -dependencies.append(.Package(url: "https://github.com/vdka/JSON.git", majorVersion: 0, minor: 16)) -#endif +import PackageDescription let package = Package( name: "Downpour", - dependencies: dependencies + platforms: [.macOS(.v10_14)], + products: [ + .library(name: "Downpour", targets: ["Downpour"]) + ], + dependencies: [ + .package(url: "https://github.com/Ponyboy47/TrailBlazer.git", from: "0.16.0"), + .package(url: "https://github.com/kareman/SwiftShell.git", from: "5.0.0") + ], + targets: [ + .target( + name: "Downpour", + dependencies: ["TrailBlazer", "SwiftShell"], + path: "Sources" + ), + .testTarget( + name: "DownpourTests", + dependencies: ["Downpour"], + path: "Tests/DownpourTests" + ) + ] ) diff --git a/README.md b/README.md index d20859c..e9ddcf1 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,74 @@ # Downpour -[![license](https://img.shields.io/github/license/mashape/apistatus.svg?maxAge=2592000)](https://github.com/steve228uk/Downpour/blob/master/LICENSE) [![Build Status](https://travis-ci.org/TryFetch/Downpour.svg?branch=master)](https://travis-ci.org/TryFetch/Downpour) +[![license](https://img.shields.io/badge/license-GPLv3-blue.svg)](https://github.com/Ponyboy47/Downpour/blob/master/LICENSE) [![Build Status](https://travis-ci.org/Ponyboy47/Downpour.svg?branch=master)](https://travis-ci.org/Ponyboy47/Downpour) Downpour was built for [Fetch](http://getfetchapp.com) — a Put.io client — to parse TV & Movie information from downloaded files. It can be used on any platform that can run Swift as it only relies on Foundation. -It can gather the following from a raw file name: +It can gather the following from a raw video file name: - TV or movie title - Year of release - TV season number - TV episode number +It can gather the following from an audio file on macOS: + +- Title +- Creation Date +- Type +- Format +- Copyrights +- Album +- Artist +- Artwork +- Publisher +- Creator +- Subject +- Summary (AKA Description) +- Contributer +- Last Modified Date +- Language +- Author + +And from Linux (Ubuntu if the libimage-exiftool-perl package is installed): + +- Title +- Creation Date +- Type +- Format +- Copyrights +- Album +- Artist +- Artwork + +NOTE: None of the fields are guaranteed to be there or even picked up, it's kinda hard to extract metadata from file names with only a few clever regexes and audio data from files is difficult to do cross-platform. Please open an issue if you know the data is there, but it's not being picked up. Also, it means everything is Optional and be sure to use `guard/if let` or nil-coalescing (`??`) to program safely. :) + +## Installation +### Swift Package Manager: +This supports SPM installation for swift 5.0 by adding the following to your Package.swift dependencies: +```swift +.package(url: "https://github.com/Ponyboy47/Downpour.git", from: "0.8.0") +``` +For swift 4.x use 0.7.x +For swift 3 use 0.4.x + ## Usage Using Downpour is easy. Just create a new instance and it'll do the rest. ```swift -let torrent = Downpour(string: filename) +let dvd_rip = Downpour(filename: filename) -let title = torrent.title -let year = torrent.year +let title = dvd_rip.title +let year = dvd_rip.year -if downpour.type == .TV { - let season = torrent.season - let episode = torrent.episode +if downpour.type == .tv { + let season = dvd_rip.season + let episode = dvd_rip.episode } ``` -## Installation - -Install manually by copying the contents of the `Sources` directory to your project or install via CocoaPods. - -```ruby -pod 'Downpour' -``` +### Common Scenarios: +- Backing up your dvd/blu-ray collection + - Designed to work with media ripped using the popular [MakeMKV](http://makemkv.com) utility +- Organizing your media files -**Note:** For Swift 2.3 please use `0.1.0` diff --git a/Sources/Downpour.swift b/Sources/Downpour.swift deleted file mode 100644 index 5107d52..0000000 --- a/Sources/Downpour.swift +++ /dev/null @@ -1,298 +0,0 @@ -// -// Downpour.swift -// Downpour -// -// Created by Stephen Radford on 18/05/2016. -// Copyright © 2016 Stephen Radford. All rights reserved. -// - -import Foundation -import PathKit - -open class Downpour: CustomStringConvertible { - - /// The raw string that has not yet been parsed by Downpour. - var rawString: String - - /// The full path to the file - var fullPath: Path - - /// The metadata for the file (generally only useful for music files) - lazy var metadata: Metadata? = { - return try? Metadata(self.fullPath) - }() - - /// The patterns that will be used to fetch various pieces of information from the rawString. - let patterns: [String: String] = [ - "pretty": "S\\d{1,2}[\\-\\.\\s_]?E\\d{1,2}", - "tricky": "[^\\d]\\d{1,2}[X\\-\\.\\s_]\\d{1,2}([^\\d]|$)", - "combined": "(?:S)?\\d{1,2}[EX\\-\\.\\s_]\\d{1,2}([^\\d]|$)", - "altSeason": "Season \\d{1,2} Episode \\d{1,2}", - "altSeasonSingle": "Season \\d{1,2}", - "altEpisodeSingle": "Episode \\d{1,2}", - "altSeason2": "[\\s_\\.\\-\\[]\\d{3}[\\s_\\.\\-\\]]", - "year": "[\\(?:\\.\\s_\\[](?:19|(?:[2-9])(?:[0-9]))\\d{2}[\\]\\s_\\.\\)]" - ] - - /// Both the season and the episode together. - lazy open var seasonEpisode: String? = { - if let match = self.rawString.range(of: self.patterns["pretty"]!, options: [.regularExpression, .caseInsensitive]) { - return self.rawString[match] - } else if var match = self.rawString.range(of: self.patterns["tricky"]!, options: [.regularExpression, .caseInsensitive]) { - match = self.rawString.index(after: match.lowerBound)..= 1 else { - let startIndex = pieces[0].index(after: pieces[0].startIndex) - return pieces[0][startIndex..=4.2) +#else +protocol CaseIterable { + static var allCases: [Self] { get } +} +#endif diff --git a/Sources/Downpour/Downpour+Protocol.swift b/Sources/Downpour/Downpour+Protocol.swift new file mode 100644 index 0000000..7770ae4 --- /dev/null +++ b/Sources/Downpour/Downpour+Protocol.swift @@ -0,0 +1,9 @@ +import TrailBlazer + +public protocol Downpourable { + associatedtype MetadataType: Metadata + var metadata: MetadataType { get } + + init?(file path: FilePath) + init(metadata: MetadataType) +} diff --git a/Sources/Downpour/Downpour.swift b/Sources/Downpour/Downpour.swift new file mode 100644 index 0000000..bb04702 --- /dev/null +++ b/Sources/Downpour/Downpour.swift @@ -0,0 +1,33 @@ +// +// Downpour.swift +// Downpour +// +// Created by Stephen Radford on 18/05/2016. +// Copyright © 2016 Stephen Radford. All rights reserved. +// + +import Foundation +import TrailBlazer + +public typealias VideoDownpour = Downpour +public typealias AudioDownpour = Downpour + +open class Downpour<_MetadataType: Metadata>: CustomStringConvertible, Downpourable { + public typealias MetadataType = _MetadataType + public let metadata: MetadataType + public lazy var title: String = { metadata.title }() + public lazy var type: MetadataFormat = { metadata.type }() + + public lazy var description: String = { + return "\(Swift.type(of: self))(metadata: \(metadata))" + }() + + public required init(metadata: MetadataType) { + self.metadata = metadata + } + + public required convenience init?(file path: FilePath) { + guard let _md = MetadataType(file: path) else { return nil } + self.init(metadata: _md) + } +} diff --git a/Sources/Downpour/Metadata/AVMetadata.swift b/Sources/Downpour/Metadata/AVMetadata.swift new file mode 100644 index 0000000..00ddaa1 --- /dev/null +++ b/Sources/Downpour/Metadata/AVMetadata.swift @@ -0,0 +1,72 @@ +#if !os(Linux) +public typealias AudioMetadata = AVMetadata + +import Foundation +import AVFoundation +import TrailBlazer + +public class AVMetadata: Metadata { + public lazy var title: String = { return self[.commonKeyTitle] ?? path.string }() + public let type: MetadataFormat = .audio + public lazy var creationDate: Date? = { + guard let dateString = self.creationDateString else { return nil } + return self.dateFormatter.date(from: dateString) + }() + public lazy var creationDateString: String? = { return self[.commonKeyCreationDate] }() + public lazy var format: String? = { return self[.commonKeyFormat] }() + public lazy var copyrights: String? = { return self[.commonKeyCopyrights] }() + public lazy var album: String? = { return self[.commonKeyAlbumName] }() + public lazy var artist: String? = { return self[.commonKeyArtist] }() + public lazy var artwork: String? = { return self[.commonKeyArtwork] }() + public lazy var publisher: String? = { return self[.commonKeyPublisher] }() + public lazy var creator: String? = { return self[.commonKeyCreator] }() + public lazy var subject: String? = { return self[.commonKeySubject] }() + public lazy var summary: String? = { return self[.commonKeyDescription] }() + public lazy var lastModifiedDate: Date? = { + guard let dateString = self.lastModifiedDateString else { return nil } + return self.dateFormatter.date(from: dateString) + }() + public lazy var lastModifiedDateString: String? = { return self[.commonKeyLastModifiedDate] }() + public lazy var language: String? = { return self[.commonKeyLanguage] }() + public lazy var author: String? = { return self[.commonKeyAuthor] }() + + private lazy var dateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + return dateFormatter + }() + + private let path: FilePath + private let metadata: [AVMetadataItem] + + public required init?(file path: FilePath) { + guard path.exists else { return nil } + self.path = path + + let asset = AVAsset(url: path.url) + self.metadata = asset.commonMetadata + } + + public subscript(_ key: AVMetadataKey) -> String? { + return AVMetadataItem.metadataItems(from: metadata, withKey: key, keySpace: nil).first?.stringValue + } +} + +extension Downpour where MetadataType: AVMetadata { + public var title: String { return metadata.title } + public var type: MetadataFormat { return metadata.type } + public var creationDate: Date? { return metadata.creationDate } + public var format: String? { return metadata.format } + public var copyrights: String? { return metadata.copyrights } + public var album: String? { return metadata.album } + public var artist: String? { return metadata.artist } + public var artwork: String? { return metadata.artwork } + public var publisher: String? { return metadata.publisher } + public var creator: String? { return metadata.creator } + public var subject: String? { return metadata.subject } + public var summary: String? { return metadata.summary } + public var lastModifiedDate: Date? { return metadata.lastModifiedDate } + public var language: String? { return metadata.language } + public var author: String? { return metadata.author } +} +#endif diff --git a/Sources/Downpour/Metadata/ExifToolMetadata.swift b/Sources/Downpour/Metadata/ExifToolMetadata.swift new file mode 100644 index 0000000..9753516 --- /dev/null +++ b/Sources/Downpour/Metadata/ExifToolMetadata.swift @@ -0,0 +1,96 @@ +#if os(Linux) +public typealias AudioMetadata = ExifToolMetadata + +import SwiftShell +import TrailBlazer +import Foundation + +public struct ExifToolMetadata: Metadata, Decodable { + public let title: String + public let creation: Date? + public let filetype: String? + public let type: MetadataFormat + public let copyrights: [String]? + public let albumName: String? + public let artist: String? + public let artwork: String? + + private static var _dateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + return dateFormatter + }() + + private enum CodingKeys: String, CodingKey { + case title = "Title" + case creationDate = "Date/Time Original" + case type = "File Type" + case format = "MIME Type" + case copyrights = "Copyright" + case albumName = "Album" + case artist = "Artist" + case artwork = "Picture" + } + + public init?(file path: FilePath) { + guard path.exists else { return nil } + guard SwiftShell.run("which", "exiftool").succeeded else { + #if os(macOS) + fatalError("Missing `exiftool` dependency! On macOS, try installing it with Homebrew by running `brew install exiftool`") + #else + fatalError("Missing `exiftool` dependency! On Ubuntu, try installing it by running `sudo apt install -y libimage-exiftool-perl`") + #endif + } + + let output = SwiftShell.run("exiftool", "-b", "-All", "-j", path.string).stdout + + guard let exifData = output.data(using: .utf8) else { return nil } + guard let data = try? JSONDecoder().decode(ExifToolMetadata.self, from: exifData) else { return nil } + + title = data.title.isEmpty ? path.lastComponentWithoutExtension ?? path.string : data.title + creation = data.creation + type = data.type + filetype = data.filetype + copyrights = data.copyrights + albumName = data.albumName + artist = data.artist + artwork = data.artwork + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + title = try container.decodeIfPresent(String.self, forKey: .title) ?? "" + if let creationString = try container.decodeIfPresent(String.self, forKey: .creationDate) { + creation = ExifToolMetadata._dateFormatter.date(from: creationString) + } else { + creation = nil + } + filetype = try container.decodeIfPresent(String.self, forKey: .type) + if let typeComponents = try container.decodeIfPresent(String.self, forKey: .format)?.lowercased().components(separatedBy: "/") { + if typeComponents.contains("video") { + throw MetadataError.incorrectFormat(found: .video, expected: .audio) + } else if typeComponents.contains("audio") { + type = .audio + } else { + throw MetadataError.incorrectFormat(found: .unknown, expected: .audio) + } + } else { + throw MetadataError.incorrectFormat(found: .unknown, expected: .audio) + } + copyrights = try container.decodeIfPresent(String.self, forKey: .copyrights)?.components(separatedBy: ", ") + albumName = try container.decodeIfPresent(String.self, forKey: .albumName) + artist = try container.decodeIfPresent(String.self, forKey: .artist) + artwork = try container.decodeIfPresent(String.self, forKey: .artwork) + } +} + +extension Downpour where MetadataType == ExifToolMetadata { + public var creation: Date? { return metadata.creation } + public var filetype: String? { return metadata.filetype } + public var copyrights: [String]? { return metadata.copyrights } + public var albumName: String? { return metadata.albumName } + public var artist: String? { return metadata.artist } + public var artwork: String? { return metadata.artwork } +} +#endif diff --git a/Sources/Downpour/Metadata/Metadata+Protocol.swift b/Sources/Downpour/Metadata/Metadata+Protocol.swift new file mode 100644 index 0000000..197b795 --- /dev/null +++ b/Sources/Downpour/Metadata/Metadata+Protocol.swift @@ -0,0 +1,8 @@ +import TrailBlazer + +public protocol Metadata { + var type: MetadataFormat { get } + var title: String { get } + + init?(file path: FilePath) +} diff --git a/Sources/Downpour/Metadata/MetadataFormat.swift b/Sources/Downpour/Metadata/MetadataFormat.swift new file mode 100644 index 0000000..421088c --- /dev/null +++ b/Sources/Downpour/Metadata/MetadataFormat.swift @@ -0,0 +1,77 @@ +public enum MetadataError: Error { + case incorrectFormat(found: MetadataFormat, expected: MetadataFormat) +} + +public struct MetadataFormat: RawRepresentable, ExpressibleByIntegerLiteral, Hashable, CustomStringConvertible { + public struct VideoFormat: RawRepresentable, ExpressibleByIntegerLiteral, Hashable, CustomStringConvertible { + public static let tv: VideoFormat = 0b0000_0001 + public static let movie: VideoFormat = 0b0000_0010 + public static let unknown: VideoFormat = 0 + + public let rawValue: UInt8 + + public var description: String { + switch self { + case .tv: return "tv" + case .movie: return "movie" + default: return "unknown" + } + } + + public init(rawValue: UInt8) { self.rawValue = rawValue } + public init(integerLiteral value: UInt8) { self.init(rawValue: value) } + + public func contains(_ element: VideoFormat) -> Bool { + return VideoFormat(rawValue: rawValue & element.rawValue) == element + } + } + + public let rawValue: UInt8 + + private var noFormat: MetadataFormat { return MetadataFormat(rawValue: rawValue & 0b0000_1111) } + public var format: VideoFormat { return VideoFormat(rawValue: rawValue >> 4) } + + public var description: String { + switch noFormat { + case .video: return "video(\(format))" + case .subtitle: return "subtitle(\(format))" + case .audio: return "audio" + default: return "unknown" + } + } + + public static let video: MetadataFormat = 0b0000_0001 + public static let tv: MetadataFormat = .video(format: .tv) + public static let movie: MetadataFormat = .video(format: .movie) + public static func video(format: VideoFormat) -> MetadataFormat { + return MetadataFormat(rawValue: MetadataFormat.video.rawValue | (format.rawValue << 4)) + } + public static func video(_ format: VideoFormat) -> MetadataFormat { + return MetadataFormat(rawValue: MetadataFormat.video.rawValue | (format.rawValue << 4)) + } + + public static let subtitle: MetadataFormat = 0b0000_0010 + public static func subtitle(format: VideoFormat) -> MetadataFormat { + return MetadataFormat(rawValue: MetadataFormat.subtitle.rawValue | (format.rawValue << 4)) + } + public static func subtitle(_ format: VideoFormat) -> MetadataFormat { + return MetadataFormat(rawValue: MetadataFormat.subtitle.rawValue | (format.rawValue << 4)) + } + + public static let audio: MetadataFormat = 0b0000_0100 + public static let unknown: MetadataFormat = 0 + + public init(rawValue: UInt8) { self.rawValue = rawValue } + public init(integerLiteral value: UInt8) { self.init(rawValue: value) } + + public static func == (lhs: MetadataFormat, rhs: MetadataFormat) -> Bool { + guard lhs.noFormat.rawValue == rhs.noFormat.rawValue else { + return lhs.rawValue == rhs.rawValue + } + return [lhs.format, rhs.format].contains(.unknown) ? true : lhs.format == rhs.format + } + + public func contains(_ element: MetadataFormat) -> Bool { + return MetadataFormat(rawValue: rawValue & element.rawValue) == element + } +} diff --git a/Sources/Downpour/Metadata/VideoMetadata.swift b/Sources/Downpour/Metadata/VideoMetadata.swift new file mode 100644 index 0000000..22b3c97 --- /dev/null +++ b/Sources/Downpour/Metadata/VideoMetadata.swift @@ -0,0 +1,254 @@ +import TrailBlazer +import Foundation + +open class VideoMetadata: Metadata, CustomStringConvertible { + public enum Pattern: String, CaseIterable { + case pretty = "S(\\d{4}|\\d{1,2})[\\-\\.\\s_]?E\\d{1,2}" + case tricky = "[^\\d](\\d{4}|\\d{1,2})[X\\-\\.\\s_]\\d{1,2}([^\\d]|$)" + case combined = "(?:S)?(\\d{4}|\\d{1,2})[EX\\-\\.\\s_]\\d{1,2}([^\\d]|$)" + case altSeason = "Season (\\d{4}|\\d{1,2}) Episode \\d{1,2}" + case altSeasonSingle = "Season (\\d{4}|\\d{1,2})" + case altEpisodeSingle = "Episode \\d{1,2}" + case altSeason2 = "[\\s_\\.\\-\\[]\\d{3}[\\s_\\.\\-\\]]" + case year = "[\\(?:\\.\\s_\\[](?:19|(?:[2-9])(?:[0-9]))\\d{2}[\\]\\s_\\.\\)]" + + #if swift(>=4.2) + #else + static var allCases: [Pattern] = [.pretty, .tricky, .combined, .altSeason, .altSeasonSingle, .altEpisodeSingle, .altSeason2, .year] + #endif + } + public static let regexOptions: String.CompareOptions = [.regularExpression, .caseInsensitive] + + public static let extensions: [MetadataFormat: [String]] = [ + .video: [ + "mkv", "flv", "vob", "ogv", "drc", + "gifv", "mng", "avi", "mov", "qt", + "wmv", "yuv", "rmvb", "asf", "amv", + "mp4", "m4p", "m4v", "mpg", "mp2", + "mpeg", "mpe", "mpv", "svi", "3g2", + "mx4", "roq", "nsv", "f4v", "f4p", + "f4a", "f4b" + ], + .subtitle: ["srt", "smi", "ssa", "ass", "vtt"] + ] + + public static let splitCharset = CharacterSet(charactersIn: "eExX-._ ") + + private let _rawString: String + private let _extension: String? + + open var description: String { + var desc: String = "\(Swift.type(of: self))(title: \(title)" + + switch type { + case .video, .subtitle: + switch type.format { + case .tv: + desc += ", season: \(String(describing: season)), episode: \(String(describing: episode))" + default: break + } + default: fatalError("Non video type found in VideoMetadata") + } + + if let year = self.year { + desc += ", year: \(year)" + } + + return desc + ")" + } + + public required init?(file path: FilePath) { + guard VideoMetadata.extensions.reduce([], { $0 + $1.value }).contains(path.extension ?? "") else { return nil } + guard let last = path.lastComponentWithoutExtension else { return nil } + _rawString = last + _extension = path.extension + } + + public init?(filename: String) { + let comps = filename.components(separatedBy: ".") + if comps.count > 1 { + _extension = comps.last + guard VideoMetadata.extensions.reduce([], { $0 + $1.value }).contains(_extension ?? "") else { return nil } + + _rawString = comps.dropLast().joined(separator: ".") + } else { + _rawString = filename + _extension = nil + } + } + + public init(name: String) { + _rawString = name + _extension = nil + } + + open lazy var type: MetadataFormat = { + // Sometimes it mestakes the x/h 264 as season 2, episode 64. I don't + // know of any shows that have 64 episode in a single season, so + // checking that the episode < 64 should be safe and will resolve these + // false positives + if season != nil && (episode ?? 64) < 64 { + return .video(.tv) + } + + return .video(.movie) + }() + + /// Iterates through all of the patterns and returns any match found + private lazy var seasonEpisode: String? = { + var _match: Range? + var _patternMatched: Pattern? + for (index, pattern) in Pattern.allCases.filter({ return $0 != .year}).enumerated() { + if let __match = _rawString.range(of: pattern, options: VideoMetadata.regexOptions) { + _match = __match + _patternMatched = Pattern.allCases[index] + break + } + } + guard var match = _match, let patternMatched = _patternMatched else { return nil } + + let matchString: String? + switch patternMatched { + case .tricky: + match = _rawString.index(after: match.lowerBound).. 1 else { return nil } + // This will never fail + guard let first = pieces.first else { fatalError("Splitting a string resulted in an empty array") } + + // The size of the first part needs to be between 1 and 2 + if first.count <= 2 && first.count >= 1 { + return Int(first.cleanedString) + } + + let startIndex = first.index(after: first.startIndex) + return Int(first[startIndex..) -> String { + func range(of pattern: Enum, options: String.CompareOptions = []) -> Range? where Enum.RawValue == String { + return self.range(of: pattern.rawValue, options: options) + } + + subscript (r: CountableClosedRange) -> Substring { get { let startIndex = self.index(self.startIndex, offsetBy: r.lowerBound) let endIndex = self.index(startIndex, offsetBy: r.upperBound - r.lowerBound) diff --git a/Sources/DownpourType.swift b/Sources/DownpourType.swift deleted file mode 100644 index a78a63f..0000000 --- a/Sources/DownpourType.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// DownpourType.swift -// Downpour -// -// Created by Stephen Radford on 18/05/2016. -// Copyright © 2015 Stephen Radford. All rights reserved. -// - -public enum DownpourType { - case tv - case movie - case music - case unknown -} diff --git a/Sources/Metadata.swift b/Sources/Metadata.swift deleted file mode 100644 index faa9a27..0000000 --- a/Sources/Metadata.swift +++ /dev/null @@ -1,256 +0,0 @@ -import Foundation -import PathKit - -#if os(Linux) -import JSON -// On Linux we define our own metadata keys that correspond with what exiftool -// uses for metadata key names -private let AVMetadataCommonKeyTitle: String = "Title" -private let AVMetadataCommonKeyCreator: String = "" -private let AVMetadataCommonKeySubject: String = "" -private let AVMetadataCommonKeyDescription: String = "" -private let AVMetadataCommonKeyPublisher: String = "Copyright" -private let AVMetadataCommonKeyContributor: String = "" -private let AVMetadataCommonKeyCreationDate: String = "Date/Time Original" -private let AVMetadataCommonKeyLastModifiedDate: String = "" -private let AVMetadataCommonKeyType: String = "File Type" -private let AVMetadataCommonKeyFormat: String = "MIME Type" -private let AVMetadataCommonKeyIdentifier: String = "" -private let AVMetadataCommonKeySource: String = "" -private let AVMetadataCommonKeyLanguage: String = "" -private let AVMetadataCommonKeyRelation: String = "" -private let AVMetadataCommonKeyLocation: String = "" -private let AVMetadataCommonKeyCopyrights: String = "Copyright" -private let AVMetadataCommonKeyAlbumName: String = "Album" -private let AVMetadataCommonKeyAuthor: String = "" -private let AVMetadataCommonKeyArtist: String = "Artist" -private let AVMetadataCommonKeyArtwork: String = "Picture" -private let AVMetadataCommonKeyMake: String = "" -private let AVMetadataCommonKeyModel: String = "" -private let AVMetadataCommonKeySoftware: String = "" -#else -// Mac OS/iOS includes the AVMetadataCommonKeys in the AVFoundation framework, -// along with the AVAsset class to make retriving file metadata easy -import AVFoundation -#endif - -class Metadata { - // These lazy vars will get metadata for all the common keys normally - // defined in AVFoundation. The vars are lazy, which means it will only - // perform the getter once - lazy var title: String? = { return self.getCommonMetadata(AVMetadataCommonKeyTitle) }() - lazy var creator: String? = { return self.getCommonMetadata(AVMetadataCommonKeyCreator) }() - lazy var subject: String? = { return self.getCommonMetadata(AVMetadataCommonKeySubject) }() - lazy var description: String? = { return self.getCommonMetadata(AVMetadataCommonKeyDescription) }() - lazy var publisher: String? = { return self.getCommonMetadata(AVMetadataCommonKeyPublisher) }() - lazy var contributer: String? = { return self.getCommonMetadata(AVMetadataCommonKeyContributor) }() - lazy var creationDate: Date? = { - guard let dateString = self.creationDateString else { return nil } - return self.dateFormatter.date(from: dateString) - }() - lazy var creationDateString: String? = { return self.getCommonMetadata(AVMetadataCommonKeyCreationDate) }() - lazy var lastModifiedDate: Date? = { - guard let dateString = self.lastModifiedDateString else { return nil } - return self.dateFormatter.date(from: dateString) - }() - lazy var lastModifiedDateString: String? = { return self.getCommonMetadata(AVMetadataCommonKeyLastModifiedDate) }() - lazy var type: String? = { return self.getCommonMetadata(AVMetadataCommonKeyType) }() - lazy var format: String? = { return self.getCommonMetadata(AVMetadataCommonKeyFormat) }() - lazy var identifier: String? = { return self.getCommonMetadata(AVMetadataCommonKeyIdentifier) }() - lazy var source: String? = { return self.getCommonMetadata(AVMetadataCommonKeySource) }() - lazy var language: String? = { return self.getCommonMetadata(AVMetadataCommonKeyLanguage) }() - lazy var relation: String? = { return self.getCommonMetadata(AVMetadataCommonKeyRelation) }() - lazy var location: String? = { return self.getCommonMetadata(AVMetadataCommonKeyLocation) }() - lazy var copyrights: String? = { return self.getCommonMetadata(AVMetadataCommonKeyCopyrights) }() - lazy var album: String? = { return self.getCommonMetadata(AVMetadataCommonKeyAlbumName) }() - lazy var author: String? = { return self.getCommonMetadata(AVMetadataCommonKeyAuthor) }() - lazy var artist: String? = { return self.getCommonMetadata(AVMetadataCommonKeyArtist) }() - lazy var artwork: String? = { return self.getCommonMetadata(AVMetadataCommonKeyArtwork) }() - lazy var make: String? = { return self.getCommonMetadata(AVMetadataCommonKeyMake) }() - lazy var model: String? = { return self.getCommonMetadata(AVMetadataCommonKeyModel) }() - lazy var software: String? = { return self.getCommonMetadata(AVMetadataCommonKeySoftware) }() - - /// A DateFormatter for the attributes that require a date from string in the ISO 8601 format - lazy var dateFormatter: DateFormatter = { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" - return dateFormatter - }() - - // The saved metadata items, so that we don't have to continually get the AVAsset or run exiftool - #if os(Linux) - private struct Metadata: JSONInitializable { - private var data: [String: String] - - init(_ str: String) throws { - try self.init(json: JSON(str)) - } - - init(json: JSON) throws { - data = [:] - - // Required values. If there are errors here, throw - data[AVMetadataCommonKeyTitle] = try json.get(AVMetadataCommonKeyTitle) - data[AVMetadataCommonKeyFormat] = try json.get(AVMetadataCommonKeyFormat) - - // Optional values, ignore errors and set to nil instead - data[AVMetadataCommonKeyPublisher] = try? json.get(AVMetadataCommonKeyPublisher) - data[AVMetadataCommonKeyCreationDate] = try? json.get(AVMetadataCommonKeyCreationDate) - data[AVMetadataCommonKeyType] = try? json.get(AVMetadataCommonKeyType) - data[AVMetadataCommonKeyCopyrights] = try? json.get(AVMetadataCommonKeyCopyrights) - data[AVMetadataCommonKeyAlbumName] = try? json.get(AVMetadataCommonKeyAlbumName) - data[AVMetadataCommonKeyArtist] = try? json.get(AVMetadataCommonKeyArtist) - data[AVMetadataCommonKeyArtwork] = try? json.get(AVMetadataCommonKeyArtwork) - } - - public func get(_ key: String) -> String? { - guard data.keys.contains(key) else { return nil } - return data[key] - } - } - - private var metadataJSON: Metadata? - #else - private var metadataItems: [AVMetadataItem]? - #endif - - /// The path to the file - private var filepath: Path - - /// The errors that occur within the Metadata class - private enum MetadataError: Swift.Error { - case missingDependency(dependency: String, helpText: String) - case couldNotGetMetadata(error: String) - case missingMetadataKey(key: String) - } - - /// Initializer that checks to make sure the dependencies are installed - init(_ path: Path) throws { - filepath = path - try hasDependencies() - } - - /// Checks to verify the system has any required dependencies. - /// - Throws: If a dependency is missing - private func hasDependencies() throws { - #if os(Linux) - let (rc, _) = execute("which exiftool") - if rc != 0 { - throw MetadataError.missingDependency(dependency: "exiftool", - helpText: "On Ubuntu systems, try installing the 'libimage-exiftool-perl' package.") - } - #endif - } - - #if os(Linux) - /// Struct used to capture the stdout and stderr of a command - private struct Output { - var stdout: String? - var stderr: String? - init (_ out: String?, _ err: String?) { - stdout = out - stderr = err - } - } - - /** - Executes a cli command - - - Parameter command: The array of strings that form the command and arguments - - - Returns: A tuple of the return code and output - */ - private func execute(_ command: String...) -> (Int32, Output) { - let task = Process() - task.launchPath = "/usr/bin/env" - task.arguments = command - - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - task.standardOutput = stdoutPipe - task.standardError = stderrPipe - task.launch() - let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - let stdout = String(data: stdoutData, encoding: .utf8) - let stderr = String(data: stderrData, encoding: .utf8) - task.waitUntilExit() - return (task.terminationStatus, Output(stdout, stderr)) - } - #endif - - /** - Get the common metadata for the specified common metadata key - - - Parameter key: The common metadata key to retrieve from the common metadata for the file - - - Returns: The string value of the common metadata, or nil. If an error occured, this will print it out - */ - public func getCommonMetadata(_ key: String) -> String? { - do { - // Try and return the Common Metadata value - return try getCM(key) - } catch { - // Print the error that occurred - print("Failed to get file metadata: \n\t\(error)") - } - // Return nil if an error occurs - return nil - } - - /** - Gets the common metadata for the key, throws errors if the key doesn't exist or if metadata could not be retrieved - - Parameter key: The common metadata key to retrieve from the common metadata for the file - - - Returns: The string value of the common metadata, or nil. - */ - private func getCM(_ key: String) throws -> String? { - #if os(Linux) - // If we're running Linux, check to see if we've saved an exiftool metadata JSON object - if metadataJSON == nil { - // If not, run the exiftool command to get the file's metadata - let (rc, output) = execute("exiftool -b -All -j \(filepath.absolute)") - // Throw an error if we failed to get the metadata - guard rc == 0 else { - var err: String = "" - if let stderr = output.stderr { - err = stderr - } - throw MetadataError.couldNotGetMetadata(error: err) - } - guard let stdout = output.stdout else { - throw MetadataError.couldNotGetMetadata(error: "File does not contain any metadata") - } - metadataJSON = try Metadata(stdout) - } - // Try and retrieve the specified property - guard let property = metadataJSON?.get(key) else { - // Throw an error if the key doesn't exist - throw MetadataError.missingMetadataKey(key: key) - } - // Otherwise, return the property - return property - #else - // If we're on macOS/iOS/tvOS/watchOS, check to see if we've saced the common metadataItems - if metadataItems == nil { - // If not, get the AVAsset from the filepath url - let asset = AVAsset(url: filepath.url) - // Save the common metadata items - metadataItems = asset.commonMetadata - // Make sure the asset had common metadata items - guard let _ = metadataItems else { - // Throw an error because the asset either has no common metadata, or something happened - throw MetadataError.couldNotGetMetadata(error: "Unkown problem getting common metadata from AVAsset") - } - } - // Try and get the metadata for the specified key - let metadata = AVMetadataItem.metadataItems(from: metadataItems!, withKey: key, keySpace: nil) - // Throws an error if there is no common metadata for the key - guard metadata.count > 0 else { - throw MetadataError.missingMetadataKey(key: key) - } - // Return the first metadata item's string value - return metadata.first?.stringValue - #endif - } -} diff --git a/Tests/DownpourTests/DownpourTests.swift b/Tests/DownpourTests/DownpourTests.swift index e38fcb4..dd031e0 100644 --- a/Tests/DownpourTests/DownpourTests.swift +++ b/Tests/DownpourTests/DownpourTests.swift @@ -9,8 +9,7 @@ import XCTest @testable import Downpour - -class DownpourTests: XCTestCase { +class DownpourVideoTests: XCTestCase { override func setUp() { super.setUp() @@ -23,171 +22,187 @@ class DownpourTests: XCTestCase { } func testMovie1() { - let downpour = Downpour(name: "Movie.Name.2015.1080p.mp4") + let downpour = Downpour(name: "Movie.Name.2013.1080p.BluRay.H264.AAC.mp4") XCTAssertEqual(downpour.title, "Movie Name") - XCTAssertEqual(downpour.year, "2015") + XCTAssertEqual(downpour.year ?? 0, 2013) XCTAssertNil(downpour.season) XCTAssertNil(downpour.episode) - XCTAssertEqual(downpour.type, .some(.movie)) + XCTAssertEqual(downpour.type, .movie) } func testMovie2() { let downpour = Downpour(name: "Movie_Name_2_2017_x264_RARBG.avi") XCTAssertEqual(downpour.title, "Movie_Name_2") // FIXME - XCTAssertEqual(downpour.year, "2017") + XCTAssertEqual(downpour.year ?? 0, 2017) XCTAssertNil(downpour.season) XCTAssertNil(downpour.episode) - XCTAssertEqual(downpour.type, .some(.movie)) + XCTAssertEqual(downpour.type, .movie) } func testStandardShow1() { let downpour = Downpour(name: "Mr.Show.Name.S01E02.Source.Quality.Etc-Group") XCTAssertEqual(downpour.title, "Mr Show Name") - XCTAssertEqual(downpour.season, "01") - XCTAssertEqual(downpour.episode, "02") - XCTAssertEqual(downpour.type, .some(.tv)) + XCTAssertEqual(downpour.season, 1) + XCTAssertEqual(downpour.episode, 2) + XCTAssertEqual(downpour.type, .tv) XCTAssertNil(downpour.year) } func testStandardShow2() { let downpour = Downpour(name: "Show.Name.S01E02") XCTAssertEqual(downpour.title, "Show Name") - XCTAssertEqual(downpour.season, "01") - XCTAssertEqual(downpour.episode, "02") - XCTAssertEqual(downpour.type, .some(.tv)) + XCTAssertEqual(downpour.season, 1) + XCTAssertEqual(downpour.episode, 2) + XCTAssertEqual(downpour.type, .tv) XCTAssertNil(downpour.year) } func testStandardShow3() { let downpour = Downpour(name: "Show Name - S01E02 - My Ep Name") XCTAssertEqual(downpour.title, "Show Name") - XCTAssertEqual(downpour.season, "01") - XCTAssertEqual(downpour.episode, "02") - XCTAssertEqual(downpour.type, .some(.tv)) + XCTAssertEqual(downpour.season, 1) + XCTAssertEqual(downpour.episode, 2) + XCTAssertEqual(downpour.type, .tv) XCTAssertNil(downpour.year) } func testStandardShow4() { let downpour = Downpour(name: "Show.2.0.Name.S01.E03.My.Ep.Name-Group") XCTAssertEqual(downpour.title, "Show 2.0 Name") - XCTAssertEqual(downpour.season, "01") - XCTAssertEqual(downpour.episode, "03") - XCTAssertEqual(downpour.type, .some(.tv)) + XCTAssertEqual(downpour.season, 1) + XCTAssertEqual(downpour.episode, 3) + XCTAssertEqual(downpour.type, .tv) XCTAssertNil(downpour.year) } func testStandardShow5() { let downpour = Downpour(name: "Show Name - S06E01 - 2009-12-20 - Ep Name") XCTAssertEqual(downpour.title, "Show Name") - XCTAssertEqual(downpour.season, "06") - XCTAssertEqual(downpour.episode, "01") - XCTAssertEqual(downpour.type, .some(.tv)) + XCTAssertEqual(downpour.season, 6) + XCTAssertEqual(downpour.episode, 1) + XCTAssertEqual(downpour.type, .tv) XCTAssertNil(downpour.year) } func testStandardShow6() { let downpour = Downpour(name: "Show Name - S06E01 - -30-") XCTAssertEqual(downpour.title, "Show Name") - XCTAssertEqual(downpour.season, "06") - XCTAssertEqual(downpour.episode, "01") - XCTAssertEqual(downpour.type, .some(.tv)) + XCTAssertEqual(downpour.season, 6) + XCTAssertEqual(downpour.episode, 1) + XCTAssertEqual(downpour.type, .tv) XCTAssertNil(downpour.year) } func testStandardShow7() { let downpour = Downpour(name: "Show.Name.S06E01.Other.WEB-DL") XCTAssertEqual(downpour.title, "Show Name") - XCTAssertEqual(downpour.season, "06") - XCTAssertEqual(downpour.episode, "01") - XCTAssertEqual(downpour.type, .some(.tv)) + XCTAssertEqual(downpour.season, 6) + XCTAssertEqual(downpour.episode, 1) + XCTAssertEqual(downpour.type, .tv) XCTAssertNil(downpour.year) } func testStandardShow8() { let downpour = Downpour(name: "Show.Name.S06E01 Some-Stuff Here") XCTAssertEqual(downpour.title, "Show Name") - XCTAssertEqual(downpour.season, "06") - XCTAssertEqual(downpour.episode, "01") - XCTAssertEqual(downpour.type, .some(.tv)) + XCTAssertEqual(downpour.season, 6) + XCTAssertEqual(downpour.episode, 1) + XCTAssertEqual(downpour.type, .tv) XCTAssertNil(downpour.year) } func testStandardShow9() { let downpour = Downpour(name: "Show.Name-0.2010.S01E02.Source.Quality.Etc-Group") XCTAssertEqual(downpour.title, "Show Name-0") // FIXME - XCTAssertEqual(downpour.season, "01") - XCTAssertEqual(downpour.episode, "02") - XCTAssertEqual(downpour.type, .some(.tv)) - XCTAssertEqual(downpour.year, "2010") + XCTAssertEqual(downpour.season, 1) + XCTAssertEqual(downpour.episode, 2) + XCTAssertEqual(downpour.type, .tv) + XCTAssertEqual(downpour.year ?? 0, 2010) } func testStandardShow10() { let downpour = Downpour(name: "Show-Name-S06E01-720p") XCTAssertEqual(downpour.title, "Show-Name") // FIXME - XCTAssertEqual(downpour.season, "06") - XCTAssertEqual(downpour.episode, "01") - XCTAssertEqual(downpour.type, .some(.tv)) + XCTAssertEqual(downpour.season, 6) + XCTAssertEqual(downpour.episode, 1) + XCTAssertEqual(downpour.type, .tv) XCTAssertNil(downpour.year) } + func testStandardShow11() { + let downpour = Downpour(name: "Show Name - s2005e01") + XCTAssertEqual(downpour.title, "Show Name") + XCTAssertEqual(downpour.season, 2005) + XCTAssertEqual(downpour.episode, 1) + XCTAssertEqual(downpour.type, .tv) + } + + func testStandardShow12() { + let downpour = Downpour(name: "Show Name - s05e01") + XCTAssertEqual(downpour.title, "Show Name") + XCTAssertEqual(downpour.season, 5) + XCTAssertEqual(downpour.episode, 1) + XCTAssertEqual(downpour.type, .tv) + } + func testFOVShow1() { let downpour = Downpour(name: "Show_Name.1x02.Source_Quality_Etc-Group") XCTAssertEqual(downpour.title, "Show_Name") // FIXME - XCTAssertEqual(downpour.season, "1") - XCTAssertEqual(downpour.episode, "02") - XCTAssertEqual(downpour.type, .some(.tv)) + XCTAssertEqual(downpour.season, 1) + XCTAssertEqual(downpour.episode, 2) + XCTAssertEqual(downpour.type, .tv) XCTAssertNil(downpour.year) } func testFOVShow2() { let downpour = Downpour(name: "Show Name 1x02") XCTAssertEqual(downpour.title, "Show Name") - XCTAssertEqual(downpour.season, "1") - XCTAssertEqual(downpour.episode, "02") - XCTAssertEqual(downpour.type, .some(.tv)) + XCTAssertEqual(downpour.season, 1) + XCTAssertEqual(downpour.episode, 2) + XCTAssertEqual(downpour.type, .tv) XCTAssertNil(downpour.year) } func testFOVShow3() { let downpour = Downpour(name: "Show Name 1x02 x264 Test") XCTAssertEqual(downpour.title, "Show Name") - XCTAssertEqual(downpour.season, "1") - XCTAssertEqual(downpour.episode, "02") - XCTAssertEqual(downpour.type, .some(.tv)) + XCTAssertEqual(downpour.season, 1) + XCTAssertEqual(downpour.episode, 2) + XCTAssertEqual(downpour.type, .tv) XCTAssertNil(downpour.year) } func testFOVShow4() { let downpour = Downpour(name: "Show Name - 1x02 - My Ep Name") XCTAssertEqual(downpour.title, "Show Name") - XCTAssertEqual(downpour.season, "1") - XCTAssertEqual(downpour.episode, "02") - XCTAssertEqual(downpour.type, .some(.tv)) + XCTAssertEqual(downpour.season, 1) + XCTAssertEqual(downpour.episode, 2) + XCTAssertEqual(downpour.type, .tv) XCTAssertNil(downpour.year) } func testFOVShow5() { let downpour = Downpour(name: "Show Name 1x02 x264 Test") XCTAssertEqual(downpour.title, "Show Name") - XCTAssertEqual(downpour.season, "1") - XCTAssertEqual(downpour.episode, "02") - XCTAssertEqual(downpour.type, .some(.tv)) + XCTAssertEqual(downpour.season, 1) + XCTAssertEqual(downpour.episode, 2) + XCTAssertEqual(downpour.type, .tv) XCTAssertNil(downpour.year) } func testFOVShow6() { let downpour = Downpour(name: "Show Name - 1x02 - My Ep Name") XCTAssertEqual(downpour.title, "Show Name") - XCTAssertEqual(downpour.season, "1") - XCTAssertEqual(downpour.episode, "02") - XCTAssertEqual(downpour.type, .some(.tv)) + XCTAssertEqual(downpour.season, 1) + XCTAssertEqual(downpour.episode, 2) + XCTAssertEqual(downpour.type, .tv) XCTAssertNil(downpour.year) } } #if os(Linux) -extension DownpourTests { - static var allTests: [(String, (DownpourTests) -> () throws -> Void)] { +extension DownpourVideoTests { + static var allTests: [(String, (DownpourVideoTests) -> () throws -> Void)] { return [ ("testMovie1", testMovie1), ("testMovie2", testMovie2), @@ -201,6 +216,8 @@ extension DownpourTests { ("testStandardShow8", testStandardShow8), ("testStandardShow9", testStandardShow9), ("testStandardShow10", testStandardShow10), + ("testStandardShow11", testStandardShow11), + ("testStandardShow12", testStandardShow12), ("testFOVShow1", testFOVShow1), ("testFOVShow2", testFOVShow2), ("testFOVShow3", testFOVShow3), diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index 30d6c30..51228e1 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -2,5 +2,5 @@ import XCTest @testable import DownpourTests XCTMain([ - testCase(DownpourTests.allTests) + testCase(DownpourVideoTests.allTests) ])