first push
This commit is contained in:
commit
135744037b
|
@ -0,0 +1,8 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/configuration/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "get",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/kean/Get",
|
||||
"state" : {
|
||||
"revision" : "12830cc64f31789ae6f4352d2d51d03a25fc3741",
|
||||
"version" : "2.1.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "jellyfin-sdk-swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/jellyfin/jellyfin-sdk-swift",
|
||||
"state" : {
|
||||
"revision" : "ecc338b4ac0a817df36e087799d7077252489ccc",
|
||||
"version" : "0.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-argument-parser",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-argument-parser",
|
||||
"state" : {
|
||||
"revision" : "c8ed701b513cf5177118a175d85fbbbcd707ab41",
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "urlqueryencoder",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/CreateAPI/URLQueryEncoder",
|
||||
"state" : {
|
||||
"revision" : "4ce950479707ea109f229d7230ec074a133b15d7",
|
||||
"version" : "0.2.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
// swift-tools-version: 5.9
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Seanut",
|
||||
platforms: [
|
||||
.macOS(.v10_15),
|
||||
],
|
||||
products: [
|
||||
.executable(name: "seanut", targets: ["Seanut"])
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"),
|
||||
.package(url: "https://github.com/jellyfin/jellyfin-sdk-swift", from: "0.3.2"),
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||
// Targets can depend on other targets in this package and products from dependencies.
|
||||
.executableTarget(
|
||||
name: "Seanut",
|
||||
dependencies: [
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
.product(name: "JellyfinAPI", package: "jellyfin-sdk-swift"),
|
||||
],
|
||||
path: "Sources"
|
||||
),
|
||||
]
|
||||
)
|
|
@ -0,0 +1,101 @@
|
|||
// Download.swift
|
||||
|
||||
import Foundation
|
||||
import ArgumentParser
|
||||
import JellyfinAPI
|
||||
|
||||
extension Seanut {
|
||||
struct DownloadCommand: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(
|
||||
commandName: "download",
|
||||
abstract: "Downloads media into specified directory (default to current directory)"
|
||||
)
|
||||
|
||||
@OptionGroup var options: Seanut.CommonArguments
|
||||
|
||||
@Argument(help: "media id returned from search")
|
||||
var mediaId: String
|
||||
|
||||
@Option(name: .shortAndLong, help: "specifies that we're only looking for a specific season of a show")
|
||||
var season: Int?
|
||||
|
||||
@Option(name: .shortAndLong, help: "location to save downloaded media")
|
||||
var output: String = FileManager.default.currentDirectoryPath
|
||||
|
||||
static var userId: String!
|
||||
static var client: JellyfinClient!
|
||||
static let TypesWithChildren: [JellyfinAPI.BaseItemKind] = [
|
||||
.musicAlbum, .musicGenre, .musicArtist, .playlist, .series, .boxSet
|
||||
]
|
||||
|
||||
func createDirectory(_ path: String) {
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: path.expandingTildeInPath.removingPercentEncoding!,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
} catch {
|
||||
fatalError("unable to create output directory at \(path).")
|
||||
}
|
||||
}
|
||||
|
||||
func downloadRoot(id: String, outputPath: URL) async {
|
||||
let downloadInfo = Paths.getItem(userID: DownloadCommand.userId, itemID: id)
|
||||
|
||||
guard let rootInfo = try? await DownloadCommand.client.send(downloadInfo).value else {
|
||||
return
|
||||
}
|
||||
|
||||
if DownloadCommand.TypesWithChildren.contains(rootInfo.type!) || rootInfo.childCount ?? 0 > 0 {
|
||||
let newPath = outputPath.appendingPathComponent(rootInfo.name!)
|
||||
createDirectory(String(newPath.absoluteString.suffix(newPath.absoluteString.count - 7)))
|
||||
|
||||
let childrenParams = Paths.GetItemsParameters(userID: DownloadCommand.userId, parentID: id)
|
||||
guard let childInfo = try? await DownloadCommand.client.send(Paths.getItems(parameters: childrenParams)).value else {
|
||||
return
|
||||
}
|
||||
|
||||
for child in childInfo.items! {
|
||||
await downloadRoot(id: child.id!, outputPath: newPath)
|
||||
}
|
||||
|
||||
} else {
|
||||
let pathUrl = URL(string: rootInfo.path!)!
|
||||
let itemName: String = rootInfo.name! + "." + pathUrl.pathExtension
|
||||
let filePath = outputPath.appendingPathComponent(itemName.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)
|
||||
await downloadItem(id: id, outputPath: filePath)
|
||||
}
|
||||
}
|
||||
|
||||
func downloadItem(id: String, outputPath: URL) async {
|
||||
do {
|
||||
let response = try await DownloadCommand.client.download(for: Paths.getDownload(itemID: id))
|
||||
|
||||
try FileManager.default.moveItem(at: response.location, to: outputPath)
|
||||
} catch {
|
||||
fatalError("Encountered \(error) downloading media. Please try again later.")
|
||||
}
|
||||
}
|
||||
|
||||
mutating func run() async {
|
||||
// checks if our specified output directory exists,
|
||||
// if it doesn't then we create it before continuing
|
||||
if !FileManager.default.fileExists(atPath: output.expandingTildeInPath) {
|
||||
createDirectory(output)
|
||||
}
|
||||
|
||||
DownloadCommand.client = JellyfinClient(
|
||||
configuration: Seanut.generateJellyfinConfiguration(url: options.domain.toURL()!),
|
||||
accessToken: Seanut.retrieveAccessToken(for: options.domain.toURL()!)
|
||||
)
|
||||
|
||||
do {
|
||||
DownloadCommand.userId = try await DownloadCommand.client.send(Paths.getCurrentUser).value.id!
|
||||
} catch {
|
||||
fatalError("failed to fetch user id. quitting...")
|
||||
}
|
||||
|
||||
await downloadRoot(id: mediaId, outputPath: URL(fileURLWithPath: output))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
// Login.swift
|
||||
|
||||
import Foundation
|
||||
import ArgumentParser
|
||||
import JellyfinAPI
|
||||
|
||||
extension Seanut {
|
||||
struct LoginCommand: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(
|
||||
commandName: "login",
|
||||
abstract: "Logs in as specified user to specified jellyfin server. caches access token for later invocations"
|
||||
)
|
||||
|
||||
@OptionGroup var options: Seanut.CommonArguments
|
||||
|
||||
@Option(name: .shortAndLong, help: "username for the jellyfin server")
|
||||
var username: String
|
||||
|
||||
@Option(name: .shortAndLong, help: "password for the jellyfin server")
|
||||
var password: String?
|
||||
|
||||
mutating func run() async {
|
||||
let client = JellyfinClient(
|
||||
configuration: Seanut.generateJellyfinConfiguration(url: options.domain.toURL()!)
|
||||
)
|
||||
|
||||
await Seanut.getAccessToken(
|
||||
client: client,
|
||||
username: username,
|
||||
password: password
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
// Search.swift
|
||||
|
||||
import Foundation
|
||||
import ArgumentParser
|
||||
import JellyfinAPI
|
||||
|
||||
extension Seanut {
|
||||
struct SearchCommand: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(
|
||||
commandName: "search",
|
||||
abstract: "Searches for <media-name>"
|
||||
)
|
||||
|
||||
@OptionGroup var options: Seanut.CommonArguments
|
||||
|
||||
@Argument(help: "media query")
|
||||
var query: String
|
||||
|
||||
static let columnWidth = 40
|
||||
|
||||
mutating func run() async {
|
||||
|
||||
let client = JellyfinClient(
|
||||
configuration: Seanut.generateJellyfinConfiguration(url: options.domain.toURL()!),
|
||||
accessToken: Seanut.retrieveAccessToken(for: options.domain.toURL()!)
|
||||
)
|
||||
|
||||
let parameters = Paths.GetItemsParameters(
|
||||
isRecursive: true,
|
||||
searchTerm: query,
|
||||
fields: [.path, .childCount],
|
||||
includeItemTypes: [.musicAlbum, .musicArtist, .movie, .book,
|
||||
.playlist, .season, .series, .audio]
|
||||
)
|
||||
|
||||
let result = try? await client.send(Paths.getItems(parameters: parameters))
|
||||
|
||||
guard let result = result else {
|
||||
print("Unable to run request")
|
||||
return
|
||||
}
|
||||
|
||||
if let items = result.value.items {
|
||||
print("Found \(items.count) results:")
|
||||
let header = "ID".padding(toLength: SearchCommand.columnWidth, withPad: " ", startingAt: 0) +
|
||||
"Type".padding(toLength: 15, withPad: " ", startingAt: 0) +
|
||||
"Name".padding(toLength: SearchCommand.columnWidth, withPad: " ", startingAt: 0)
|
||||
print(header)
|
||||
|
||||
for i in items {
|
||||
let item = i.id!.padding(toLength: SearchCommand.columnWidth, withPad: " ", startingAt: 0) +
|
||||
i.type!.rawValue.padding(toLength: 15, withPad: " ", startingAt: 0) +
|
||||
i.name!.padding(toLength: SearchCommand.columnWidth, withPad: " ", startingAt: 0)
|
||||
print(item)
|
||||
}
|
||||
} else {
|
||||
print("Found no results. Please try your query again...")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
// CustomTypes.swift
|
||||
|
||||
import ArgumentParser
|
||||
|
||||
enum MediaType: String, ExpressibleByArgument {
|
||||
case book, movie, season, series, playlist, album, artist
|
||||
}
|
||||
|
||||
extension MediaType: CustomStringConvertible {
|
||||
var description: String {
|
||||
return rawValue.capitalized
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
// The Swift Programming Language
|
||||
// https://docs.swift.org/swift-book
|
||||
|
||||
import Foundation
|
||||
import ArgumentParser
|
||||
import JellyfinAPI
|
||||
import CryptoKit
|
||||
|
||||
let SeanutVersion = "0.0.1"
|
||||
|
||||
@main
|
||||
struct Seanut: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(
|
||||
abstract: "A utility to download media from jellyfin servers",
|
||||
subcommands: [SearchCommand.self, DownloadCommand.self, LoginCommand.self],
|
||||
defaultSubcommand: SearchCommand.self
|
||||
)
|
||||
|
||||
struct CommonArguments: ParsableArguments {
|
||||
@Option(name: .shortAndLong, help: "jellyfin server domain name")
|
||||
var domain: String
|
||||
}
|
||||
|
||||
static func generateJellyfinConfiguration(url: URL) -> JellyfinClient.Configuration {
|
||||
let hostname = Host.current().localizedName ?? "Seanut-Device"
|
||||
let digest = SHA256.hash(data: hostname.data(using: .utf8)!)
|
||||
|
||||
return JellyfinClient.Configuration(
|
||||
url: url,
|
||||
client: "SeanutSwift",
|
||||
deviceName: hostname,
|
||||
deviceID: digest.compactMap({ String(format: "%02x", $0) }).joined(),
|
||||
version: SeanutVersion
|
||||
)
|
||||
}
|
||||
|
||||
static func getAccessToken(client: JellyfinClient, username: String, password: String?) async {
|
||||
let pass: String? = password ?? {
|
||||
print("password>", terminator: " ")
|
||||
return readLine(strippingNewline: true)
|
||||
}()
|
||||
let domain = client.configuration.url.host!
|
||||
|
||||
do {
|
||||
let auth = try await client.signIn(username: username,
|
||||
password: pass ?? "")
|
||||
let fileName = FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".seanut/\(domain)")
|
||||
|
||||
try auth.accessToken!.write(to: fileName, atomically: false, encoding: .utf8)
|
||||
print("Access token retrieved and saved to ~/.seanut/\(domain)")
|
||||
} catch {
|
||||
fatalError("failed to login with provided credentials. please try again.")
|
||||
}
|
||||
}
|
||||
|
||||
static func retrieveAccessToken(for domain: URL) -> String? {
|
||||
let tokenPath = FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".seanut/\(domain.host!)")
|
||||
return try? String(contentsOf: tokenPath)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
// String.swift
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
public var expandingTildeInPath: String {
|
||||
return NSString(string: self).expandingTildeInPath
|
||||
}
|
||||
|
||||
func toURL() -> URL? {
|
||||
return hasPrefix("https://") ?
|
||||
URL(string: self) : URL(string: "https://\(self)")
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue