rewrote entire tool to not use JellyfinAPI library for linux compat
This commit is contained in:
parent
ad83c15e9c
commit
baa03a4281
|
@ -9,15 +9,6 @@
|
|||
"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",
|
||||
|
@ -27,6 +18,15 @@
|
|||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-crypto",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-crypto",
|
||||
"state" : {
|
||||
"revision" : "cc76b894169a3c86b71bac10c78a4db6beb7a9ad",
|
||||
"version" : "3.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-log",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
@ -35,15 +35,6 @@
|
|||
"revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5",
|
||||
"version" : "1.5.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "urlqueryencoder",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/CreateAPI/URLQueryEncoder",
|
||||
"state" : {
|
||||
"revision" : "4ce950479707ea109f229d7230ec074a133b15d7",
|
||||
"version" : "0.2.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
|
|
|
@ -13,8 +13,9 @@ let package = Package(
|
|||
],
|
||||
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"),
|
||||
.package(url: "https://github.com/apple/swift-log", from: "1.5.4"),
|
||||
.package(url: "https://github.com/kean/Get", from: "2.1.6"),
|
||||
.package(url: "https://github.com/apple/swift-crypto", from: "3.2.0"),
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||
|
@ -23,8 +24,9 @@ let package = Package(
|
|||
name: "Seanut",
|
||||
dependencies: [
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
.product(name: "JellyfinAPI", package: "jellyfin-sdk-swift"),
|
||||
.product(name: "Logging", package: "swift-log"),
|
||||
.product(name: "Get", package: "Get"),
|
||||
.product(name: "Crypto", package: "swift-crypto"),
|
||||
],
|
||||
path: "Sources"
|
||||
),
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import Foundation
|
||||
import ArgumentParser
|
||||
import JellyfinAPI
|
||||
import Get
|
||||
|
||||
extension Seanut {
|
||||
struct DownloadCommand: AsyncParsableCommand {
|
||||
|
@ -23,9 +23,10 @@ extension Seanut {
|
|||
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
|
||||
static var client: APIClient!
|
||||
static var config: RequestConfiguration!
|
||||
static let TypesWithChildren: [String] = [
|
||||
"MusicAlbum", "MusicGenre", "MusicArtist", "Playlist", "Series", "BoxSet"
|
||||
]
|
||||
|
||||
func createDirectory(_ path: String) {
|
||||
|
@ -41,9 +42,14 @@ extension Seanut {
|
|||
}
|
||||
|
||||
func downloadRoot(id: String, outputPath: URL) async {
|
||||
let downloadInfo = Paths.getItem(userID: DownloadCommand.userId, itemID: id)
|
||||
let downloadInfoReq: Request<Item> = Seanut.jellyfinRequest(
|
||||
result: Item(),
|
||||
config: DownloadCommand.config,
|
||||
path: "/Users/\(DownloadCommand.userId!)/Items/\(id)",
|
||||
method: .get
|
||||
)
|
||||
|
||||
guard let rootInfo = try? await DownloadCommand.client.send(downloadInfo).value else {
|
||||
guard let rootInfo = try? await DownloadCommand.client.send(downloadInfoReq).value else {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -51,14 +57,20 @@ extension Seanut {
|
|||
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 {
|
||||
let childReq: Request<[Item]> = Seanut.jellyfinRequest(
|
||||
result: [Item()],
|
||||
config: DownloadCommand.config,
|
||||
path: "/Items",
|
||||
method: .get,
|
||||
query: [("userId", DownloadCommand.userId!), ("parentId", id)]
|
||||
)
|
||||
guard let childInfo = try? await DownloadCommand.client.send(childReq).value else {
|
||||
return
|
||||
}
|
||||
|
||||
Seanut.Logger?.info("Fetching children for \(rootInfo.name!)...")
|
||||
|
||||
for child in childInfo.items! {
|
||||
for child in childInfo {
|
||||
await downloadRoot(id: child.id!, outputPath: newPath)
|
||||
}
|
||||
|
||||
|
@ -74,7 +86,13 @@ extension Seanut {
|
|||
|
||||
func downloadItem(id: String, outputPath: URL) async {
|
||||
do {
|
||||
let response = try await DownloadCommand.client.download(for: Paths.getDownload(itemID: id))
|
||||
let dlReq: Request<Data> = Seanut.jellyfinRequest(
|
||||
result: Data(),
|
||||
config: DownloadCommand.config,
|
||||
path: "/Items/\(id)/Download",
|
||||
method: .get
|
||||
)
|
||||
let response = try await DownloadCommand.client.download(for: dlReq)
|
||||
|
||||
try FileManager.default.moveItem(at: response.location, to: outputPath)
|
||||
} catch {
|
||||
|
@ -90,14 +108,23 @@ extension Seanut {
|
|||
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()!)
|
||||
|
||||
DownloadCommand.config = Seanut.generateJellyfinConfiguration(
|
||||
url: options.domain.toURL()!,
|
||||
token: Seanut.retrieveAccessToken(for: options.domain.toURL()!)
|
||||
)
|
||||
|
||||
DownloadCommand.client = APIClient(baseURL: options.domain.toURL()!)
|
||||
|
||||
do {
|
||||
DownloadCommand.userId = try await DownloadCommand.client.send(Paths.getCurrentUser).value.id!
|
||||
let getUserReq: Request<User> = Seanut.jellyfinRequest(
|
||||
result: User(),
|
||||
config: DownloadCommand.config,
|
||||
path: "/Users/Me",
|
||||
method: .get
|
||||
)
|
||||
|
||||
DownloadCommand.userId = try await DownloadCommand.client.send(getUserReq).value.id!
|
||||
} catch {
|
||||
fatalError("failed to fetch user id. quitting...")
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import Foundation
|
||||
import ArgumentParser
|
||||
import JellyfinAPI
|
||||
import Get
|
||||
|
||||
extension Seanut {
|
||||
struct LoginCommand: AsyncParsableCommand {
|
||||
|
@ -31,9 +31,8 @@ extension Seanut {
|
|||
}
|
||||
}
|
||||
|
||||
let client = JellyfinClient(
|
||||
configuration: Seanut.generateJellyfinConfiguration(url: options.domain.toURL()!)
|
||||
)
|
||||
let client = APIClient(baseURL: options.domain.toURL()!)
|
||||
// Seanut.generateJellyfinConfiguration(url: options.domain.toURL()!)
|
||||
|
||||
await Seanut.getAccessToken(
|
||||
client: client,
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import Foundation
|
||||
import ArgumentParser
|
||||
import JellyfinAPI
|
||||
import Get
|
||||
|
||||
extension Seanut {
|
||||
struct SearchCommand: AsyncParsableCommand {
|
||||
|
@ -17,31 +17,33 @@ extension Seanut {
|
|||
var query: String
|
||||
|
||||
static let columnWidth = 40
|
||||
|
||||
var queryItems: [(String, String)] {
|
||||
[
|
||||
("isRecursive", "true"),
|
||||
("searchTerm", query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!),
|
||||
("fields", "Path,ChildCount".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!),
|
||||
("includeItemTypes", "MusicAlbum,MusicArtist,Movie,Book,Playlist,Series,Audio".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!)
|
||||
]
|
||||
}
|
||||
|
||||
mutating func run() async {
|
||||
Seanut.maybeSetLogger(options)
|
||||
let config = Seanut.generateJellyfinConfiguration(url: options.domain.toURL()!,
|
||||
token: Seanut.retrieveAccessToken(for: options.domain.toURL()!))
|
||||
|
||||
let client = JellyfinClient(
|
||||
configuration: Seanut.generateJellyfinConfiguration(url: options.domain.toURL()!),
|
||||
accessToken: Seanut.retrieveAccessToken(for: options.domain.toURL()!)
|
||||
let client = APIClient(baseURL: options.domain.toURL()!)
|
||||
let searchRequest: Request<[Item]> = Seanut.jellyfinRequest(
|
||||
result: [Item()],
|
||||
config: config,
|
||||
path: "/Items",
|
||||
method: .get,
|
||||
query: queryItems
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
let response = try? await client.send(searchRequest).value
|
||||
|
||||
if let items = result.value.items {
|
||||
if let items = response {
|
||||
print("Found \(items.count) results:")
|
||||
let header = "ID".padding(toLength: SearchCommand.columnWidth, withPad: " ", startingAt: 0) +
|
||||
"Type".padding(toLength: 15, withPad: " ", startingAt: 0) +
|
||||
|
@ -50,7 +52,7 @@ extension Seanut {
|
|||
|
||||
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.type!.padding(toLength: 15, withPad: " ", startingAt: 0) +
|
||||
i.name!.padding(toLength: SearchCommand.columnWidth, withPad: " ", startingAt: 0)
|
||||
print(item)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
struct AuthenticationResponse: Codable {
|
||||
let accessToken: String?
|
||||
|
||||
init() {
|
||||
self.accessToken = nil
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken = "AccessToken"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
// Item.swift
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Item: Codable {
|
||||
let id: String?
|
||||
let type: String?
|
||||
let name: String?
|
||||
let childCount: Int?
|
||||
let path: String?
|
||||
|
||||
init() {
|
||||
self.id = nil
|
||||
self.type = nil
|
||||
self.name = nil
|
||||
self.childCount = nil
|
||||
self.path = nil
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
// User.swift
|
||||
|
||||
struct User: Codable {
|
||||
let id: String?
|
||||
|
||||
init() { id = nil }
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
// UserAuthentication.swift
|
||||
|
||||
import Foundation
|
||||
|
||||
struct UserAuthentication: Codable {
|
||||
var password: String?
|
||||
var username: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case password = "Password"
|
||||
case username = "Username"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
struct RequestConfiguration {
|
||||
let url: URL
|
||||
let client: String
|
||||
let deviceName: String
|
||||
let deviceId: String
|
||||
let version: String
|
||||
let token: String?
|
||||
|
||||
var authString: String {
|
||||
"MediaBrowser DeviceId=\(deviceId), Device=\(deviceName), Client=\(client), Version=\(version), Token=\(token ?? "")"
|
||||
}
|
||||
}
|
|
@ -3,8 +3,8 @@
|
|||
|
||||
import Foundation
|
||||
import ArgumentParser
|
||||
import JellyfinAPI
|
||||
import CryptoKit
|
||||
import Get
|
||||
import Crypto
|
||||
import Logging
|
||||
|
||||
let SeanutVersion = "0.0.1"
|
||||
|
@ -31,34 +31,64 @@ struct Seanut: AsyncParsableCommand {
|
|||
Seanut.Logger = opts.verbose ? Logging.Logger(label: "garden.focks.SeanutSwift.logger") : nil
|
||||
}
|
||||
|
||||
static func generateJellyfinConfiguration(url: URL) -> JellyfinClient.Configuration {
|
||||
static func generateJellyfinConfiguration(url: URL, token: String? = nil) -> RequestConfiguration {
|
||||
let hostname = Host.current().localizedName ?? "Seanut-Device"
|
||||
let digest = SHA256.hash(data: hostname.data(using: .utf8)!)
|
||||
|
||||
return JellyfinClient.Configuration(
|
||||
return RequestConfiguration(
|
||||
url: url,
|
||||
client: "SeanutSwift",
|
||||
deviceName: hostname,
|
||||
deviceID: digest.compactMap({ String(format: "%02x", $0) }).joined(),
|
||||
version: SeanutVersion
|
||||
deviceId: digest.compactMap({ String(format: "%02x", $0) }).joined(),
|
||||
version: SeanutVersion,
|
||||
token: token
|
||||
)
|
||||
}
|
||||
|
||||
static func getAccessToken(client: JellyfinClient, username: String, password: String?) async {
|
||||
static func jellyfinRequest<T>(
|
||||
result: T,
|
||||
config: RequestConfiguration,
|
||||
path: String,
|
||||
method: HTTPMethod,
|
||||
body: Encodable? = nil,
|
||||
query: [(String, String)]? = nil
|
||||
) -> Request<T> {
|
||||
let id = String(path.split(separator: "/").last!)
|
||||
return Request(
|
||||
path: path,
|
||||
method: method,
|
||||
query: query,
|
||||
body: body,
|
||||
headers: [ "Authorization": config.authString ],
|
||||
id: id
|
||||
)
|
||||
}
|
||||
|
||||
static func getAccessToken(client: APIClient, username: String, password: String?) async {
|
||||
let pass: String? = password ?? {
|
||||
print("password>", terminator: " ")
|
||||
return readLine(strippingNewline: true)
|
||||
}()
|
||||
let domain = client.configuration.url.host!
|
||||
|
||||
let domain = client.configuration.baseURL!
|
||||
let configuration = generateJellyfinConfiguration(url: domain)
|
||||
let fileName = FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".seanut/\(domain)")
|
||||
|
||||
do {
|
||||
let auth = try await client.signIn(username: username,
|
||||
password: pass ?? "")
|
||||
let fileName = FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".seanut/\(domain)")
|
||||
// FIXME: remove helper function "signIn" and call path directly
|
||||
let signIn: Request<AuthenticationResponse> = jellyfinRequest(
|
||||
result: AuthenticationResponse(),
|
||||
config: configuration,
|
||||
path: "/Users/AuthenticateByName",
|
||||
method: .post,
|
||||
body: UserAuthentication(password: pass, username: username)
|
||||
)
|
||||
|
||||
let resp = try await client.send(signIn).value
|
||||
|
||||
try auth.accessToken!.write(to: fileName, atomically: false, encoding: .utf8)
|
||||
try resp.accessToken!.write(to: fileName, atomically: false, encoding: .utf8)
|
||||
print("Access token retrieved ☑️")
|
||||
} catch {
|
||||
print("Failed to login with provided credentials. please try again later.")
|
||||
|
|
Loading…
Reference in New Issue