Compare commits
8 Commits
ad83c15e9c
...
4891cdc30b
Author | SHA1 | Date |
---|---|---|
focks | 4891cdc30b | |
a. fox | 637f011814 | |
a. fox | 54f71ce23f | |
a. fox | d8c47bd905 | |
a. fox | b00669a167 | |
a. fox | ecdb1bdc35 | |
a. fox | d9cbb90575 | |
a. fox | 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"
|
||||
),
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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...")
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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,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
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
// User.swift
|
||||
|
||||
struct User: Codable {
|
||||
let id: String?
|
||||
|
||||
init() { id = nil }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id = "Id"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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,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.")
|
||||
|
|
Loading…
Reference in New Issue