rewrote entire tool to not use JellyfinAPI library for linux compat

This commit is contained in:
a. fox 2024-02-29 16:40:18 -05:00
parent ad83c15e9c
commit baa03a4281
11 changed files with 192 additions and 73 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,14 @@
import Foundation
struct AuthenticationResponse: Codable {
let accessToken: String?
init() {
self.accessToken = nil
}
enum CodingKeys: String, CodingKey {
case accessToken = "AccessToken"
}
}

19
Sources/Models/Item.swift Normal file
View File

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

View File

@ -0,0 +1,7 @@
// User.swift
struct User: Codable {
let id: String?
init() { id = nil }
}

View File

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

View File

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

View File

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