Merge pull request 'Linux Compat Fixes' (#1) from get-rewrite into main

Reviewed-on: #1
This commit is contained in:
focks 2024-03-01 03:39:18 +00:00
commit 4891cdc30b
13 changed files with 243 additions and 92 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"
),

15
Sources/APIClient.swift Normal file
View File

@ -0,0 +1,15 @@
// APIClient.swift
import Foundation
import Get
extension APIClient {
#if os(Linux)
public func download<T>(for request: Request<T>, to location: URL) async throws {
let data = try await data(for: request).value
try data.write(to: location)
}
#endif
}

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,32 +57,54 @@ 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<SearchResult> = Seanut.jellyfinRequest(
result: SearchResult(),
config: DownloadCommand.config,
path: "/Items",
method: .get,
query: [
("userId", DownloadCommand.userId!),
("parentId", id),
("fields", "Path,ChildCount")
]
)
guard let childInfo = try? await DownloadCommand.client.send(childReq).value.items 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)
}
} else {
let pathUrl = URL(string: rootInfo.path!)!
Seanut.Logger?.info("Downloading \(rootInfo.name!)...")
let pathUrl = URL(fileURLWithPath: rootInfo.path!)
let itemName: String = rootInfo.name! + "." + pathUrl.pathExtension
let filePath = outputPath.appendingPathComponent(itemName.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)
Seanut.Logger?.info("Downloading \(rootInfo.name!)...")
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))
let dlReq: Request<Data> = Seanut.jellyfinRequest(
result: Data(),
config: DownloadCommand.config,
path: "/Items/\(id)/Download",
method: .get
)
#if os(Linux)
try await DownloadCommand.client.download(for: dlReq, to: outputPath)
#else
let response = try await DownloadCommand.client.download(for: dlReq)
try FileManager.default.moveItem(at: response.location, to: outputPath)
#endif
} catch {
fatalError("Encountered \(error) downloading media. Please try again later.")
}
@ -90,14 +118,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 {
@ -21,19 +21,21 @@ extension Seanut {
mutating func run() async {
Seanut.maybeSetLogger(options)
let seanutCacheFolder = "~/.seanut/".expandingTildeInPath
let seanutCacheFolder = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".seanut/")
if !FileManager.default.fileExists(atPath: seanutCacheFolder.absoluteString) {
if !FileManager.default.fileExists(atPath: seanutCacheFolder) {
do {
try FileManager.default.createDirectory(at: seanutCacheFolder, withIntermediateDirectories: false)
try FileManager.default.createDirectory(
at: URL(fileURLWithPath: seanutCacheFolder),
withIntermediateDirectories: false
)
} catch {
fatalError("could not create seanut token cache folder. quitting...")
}
}
let client = JellyfinClient(
configuration: Seanut.generateJellyfinConfiguration(url: options.domain.toURL()!)
)
let client = APIClient(baseURL: 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 {
@ -20,28 +20,28 @@ extension Seanut {
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<SearchResult> = Seanut.jellyfinRequest(
result: SearchResult(),
config: config,
path: "/Items",
method: .get,
query: [
("recursive", "true"),
("fields", "Path,ChildCount"),
("searchTerm", query),
("includeItemTypes", "MusicAlbum,MusicArtist,Movie,Book,Playlist,Series,Audio")
]
)
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?.items {
print("Found \(items.count) results:")
let header = "ID".padding(toLength: SearchCommand.columnWidth, withPad: " ", startingAt: 0) +
"Type".padding(toLength: 15, withPad: " ", startingAt: 0) +
@ -50,7 +50,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

@ -1,13 +0,0 @@
// 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
}
}

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

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

@ -0,0 +1,37 @@
// Item.swift
import Foundation
struct SearchResult: Codable {
let items: [Item]?
enum CodingKeys: String, CodingKey {
case items = "Items"
}
init() { self.items = nil }
}
struct Item: Codable {
let id: String?
let type: String?
let name: String?
let childCount: Int?
let path: String?
enum CodingKeys: String, CodingKey {
case id = "Id"
case type = "Type"
case name = "Name"
case childCount = "ChildCount"
case path = "Path"
}
init() {
self.id = nil
self.type = nil
self.name = nil
self.childCount = nil
self.path = nil
}
}

11
Sources/Models/User.swift Normal file
View File

@ -0,0 +1,11 @@
// User.swift
struct User: Codable {
let id: String?
init() { id = nil }
enum CodingKeys: String, CodingKey {
case id = "Id"
}
}

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 = "Pw"
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,61 @@ 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 = "~/.seanut/\(domain.host!)".expandingTildeInPath
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)
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 resp.accessToken!.write(to: URL(fileURLWithPath: fileName), atomically: false, encoding: .utf8)
print("Access token retrieved ☑️")
} catch {
print("Failed to login with provided credentials. please try again later.")