first push

This commit is contained in:
a. fox 2024-02-16 14:58:42 -05:00
commit 135744037b
9 changed files with 366 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

41
Package.resolved Normal file
View File

@ -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
}

30
Package.swift Normal file
View File

@ -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"
),
]
)

View File

@ -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))
}
}
}

View File

@ -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
)
}
}
}

View File

@ -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...")
}
}
}
}

13
Sources/CustomTypes.swift Normal file
View File

@ -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
}
}

64
Sources/Seanut.swift Normal file
View File

@ -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)
}
}

14
Sources/String.swift Normal file
View File

@ -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)")
}
}