mirror of
https://github.com/yattee/yattee.git
synced 2025-08-06 10:44:06 +00:00
Add support for Piped accounts and subscriptions
This commit is contained in:
@@ -8,7 +8,9 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
|
||||
let instanceID: String
|
||||
var name: String?
|
||||
let url: String
|
||||
let sid: String
|
||||
let username: String
|
||||
let password: String?
|
||||
var token: String?
|
||||
let anonymous: Bool
|
||||
|
||||
init(
|
||||
@@ -16,7 +18,9 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
|
||||
instanceID: String? = nil,
|
||||
name: String? = nil,
|
||||
url: String? = nil,
|
||||
sid: String? = nil,
|
||||
username: String? = nil,
|
||||
password: String? = nil,
|
||||
token: String? = nil,
|
||||
anonymous: Bool = false
|
||||
) {
|
||||
self.anonymous = anonymous
|
||||
@@ -25,27 +29,29 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
|
||||
self.instanceID = instanceID ?? UUID().uuidString
|
||||
self.name = name
|
||||
self.url = url ?? ""
|
||||
self.sid = sid ?? ""
|
||||
self.username = username ?? ""
|
||||
self.token = token
|
||||
self.password = password ?? ""
|
||||
}
|
||||
|
||||
var instance: Instance! {
|
||||
Defaults[.instances].first { $0.id == instanceID }
|
||||
}
|
||||
|
||||
var anonymizedSID: String {
|
||||
guard sid.count > 3 else {
|
||||
return ""
|
||||
var shortUsername: String {
|
||||
guard username.count > 10 else {
|
||||
return username
|
||||
}
|
||||
|
||||
let index = sid.index(sid.startIndex, offsetBy: 4)
|
||||
return String(sid[..<index])
|
||||
let index = username.index(username.startIndex, offsetBy: 11)
|
||||
return String(username[..<index])
|
||||
}
|
||||
|
||||
var description: String {
|
||||
(name != nil && name!.isEmpty) ? "Unnamed (\(anonymizedSID))" : name!
|
||||
(name != nil && name!.isEmpty) ? shortUsername : name!
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(sid)
|
||||
hasher.combine(username)
|
||||
}
|
||||
}
|
||||
|
@@ -5,7 +5,7 @@ import SwiftUI
|
||||
final class AccountValidator: Service {
|
||||
let app: Binding<VideosApp>
|
||||
let url: String
|
||||
let account: Account?
|
||||
let account: Account!
|
||||
|
||||
var formObjectID: Binding<String>
|
||||
var isValid: Binding<Bool>
|
||||
@@ -46,7 +46,11 @@ final class AccountValidator: Service {
|
||||
return
|
||||
}
|
||||
|
||||
$0.headers["Cookie"] = self.cookieHeader
|
||||
$0.headers["Cookie"] = self.invidiousCookieHeader
|
||||
}
|
||||
|
||||
configure("/login", requestMethods: [.post]) {
|
||||
$0.headers["Content-Type"] = "application/json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,20 +88,27 @@ final class AccountValidator: Service {
|
||||
}
|
||||
}
|
||||
|
||||
func validateInvidiousAccount() {
|
||||
func validateAccount() {
|
||||
reset()
|
||||
|
||||
feed
|
||||
.load()
|
||||
.onSuccess { _ in
|
||||
guard self.account!.sid == self.formObjectID.wrappedValue else {
|
||||
accountRequest
|
||||
.onSuccess { response in
|
||||
guard self.account!.username == self.formObjectID.wrappedValue else {
|
||||
return
|
||||
}
|
||||
|
||||
self.isValid.wrappedValue = true
|
||||
switch self.app.wrappedValue {
|
||||
case .invidious:
|
||||
self.isValid.wrappedValue = true
|
||||
case .piped:
|
||||
let error = response.json.dictionaryValue["error"]?.string
|
||||
let token = response.json.dictionaryValue["token"]?.string
|
||||
self.isValid.wrappedValue = error?.isEmpty ?? !(token?.isEmpty ?? true)
|
||||
self.error!.wrappedValue = error
|
||||
}
|
||||
}
|
||||
.onFailure { _ in
|
||||
guard self.account!.sid == self.formObjectID.wrappedValue else {
|
||||
guard self.account!.username == self.formObjectID.wrappedValue else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -109,6 +120,15 @@ final class AccountValidator: Service {
|
||||
}
|
||||
}
|
||||
|
||||
var accountRequest: Request {
|
||||
switch app.wrappedValue {
|
||||
case .invidious:
|
||||
return feed.load()
|
||||
case .piped:
|
||||
return login.request(.post, json: ["username": account.username, "password": account.password])
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
isValid.wrappedValue = false
|
||||
isValidated.wrappedValue = false
|
||||
@@ -116,8 +136,12 @@ final class AccountValidator: Service {
|
||||
error?.wrappedValue = nil
|
||||
}
|
||||
|
||||
var cookieHeader: String {
|
||||
"SID=\(account!.sid)"
|
||||
var invidiousCookieHeader: String {
|
||||
"SID=\(account.username)"
|
||||
}
|
||||
|
||||
var login: Resource {
|
||||
resource("/login")
|
||||
}
|
||||
|
||||
var feed: Resource {
|
||||
|
@@ -15,7 +15,8 @@ struct AccountsBridge: Defaults.Bridge {
|
||||
"instanceID": value.instanceID,
|
||||
"name": value.name ?? "",
|
||||
"apiURL": value.url,
|
||||
"sid": value.sid
|
||||
"username": value.username,
|
||||
"password": value.password ?? ""
|
||||
]
|
||||
}
|
||||
|
||||
@@ -25,13 +26,14 @@ struct AccountsBridge: Defaults.Bridge {
|
||||
let id = object["id"],
|
||||
let instanceID = object["instanceID"],
|
||||
let url = object["apiURL"],
|
||||
let sid = object["sid"]
|
||||
let username = object["username"]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let name = object["name"] ?? ""
|
||||
let password = object["password"]
|
||||
|
||||
return Account(id: id, instanceID: instanceID, name: name, url: url, sid: sid)
|
||||
return Account(id: id, instanceID: instanceID, name: name, url: url, username: username, password: password)
|
||||
}
|
||||
}
|
||||
|
@@ -35,7 +35,7 @@ final class AccountsModel: ObservableObject {
|
||||
}
|
||||
|
||||
var signedIn: Bool {
|
||||
!isEmpty && !current.anonymous
|
||||
!isEmpty && !current.anonymous && api.signedIn
|
||||
}
|
||||
|
||||
init() {
|
||||
@@ -74,8 +74,14 @@ final class AccountsModel: ObservableObject {
|
||||
Defaults[.accounts].first { $0.id == id }
|
||||
}
|
||||
|
||||
static func add(instance: Instance, name: String, sid: String) -> Account {
|
||||
let account = Account(instanceID: instance.id, name: name, url: instance.apiURL, sid: sid)
|
||||
static func add(instance: Instance, name: String, username: String, password: String? = nil) -> Account {
|
||||
let account = Account(
|
||||
instanceID: instance.id,
|
||||
name: name,
|
||||
url: instance.apiURL,
|
||||
username: username,
|
||||
password: password
|
||||
)
|
||||
Defaults[.accounts].append(account)
|
||||
|
||||
return account
|
||||
|
@@ -70,7 +70,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
func configure() {
|
||||
configure {
|
||||
if !self.account.sid.isEmpty {
|
||||
if !self.account.username.isEmpty {
|
||||
$0.headers["Cookie"] = self.cookieHeader
|
||||
}
|
||||
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
|
||||
@@ -160,7 +160,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
private var cookieHeader: String {
|
||||
"SID=\(account.sid)"
|
||||
"SID=\(account.username)"
|
||||
}
|
||||
|
||||
var popular: Resource? {
|
||||
@@ -185,8 +185,18 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
}
|
||||
|
||||
func channelSubscription(_ id: String) -> Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions")).child(id)
|
||||
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
.child(channelID)
|
||||
.request(.post)
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void) {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
.child(channelID)
|
||||
.request(.delete)
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func channel(_ id: String) -> Resource {
|
||||
@@ -202,7 +212,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
var playlists: Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
|
||||
if account.isNil || account.anonymous {
|
||||
return nil
|
||||
}
|
||||
|
||||
return resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
|
||||
}
|
||||
|
||||
func playlist(_ id: String) -> Resource? {
|
||||
|
@@ -4,6 +4,8 @@ import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
static var authorizedEndpoints = ["subscriptions", "subscribe", "unsubscribe"]
|
||||
|
||||
@Published var account: Account!
|
||||
|
||||
var anonymousAccount: Account {
|
||||
@@ -27,10 +29,16 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
func configure() {
|
||||
invalidateConfiguration()
|
||||
|
||||
configure {
|
||||
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
|
||||
}
|
||||
|
||||
configure(whenURLMatches: { url in self.needsAuthorization(url) }) {
|
||||
$0.headers["Authorization"] = self.account.token
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> Channel? in
|
||||
PipedAPI.extractChannel(from: content.json)
|
||||
}
|
||||
@@ -54,6 +62,38 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
configureTransformer(pathPattern("suggestions")) { (content: Entity<JSON>) -> [String] in
|
||||
content.json.arrayValue.map(String.init)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("subscriptions")) { (content: Entity<JSON>) -> [Channel] in
|
||||
content.json.arrayValue.map { PipedAPI.extractChannel(from: $0)! }
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("feed")) { (content: Entity<JSON>) -> [Video] in
|
||||
content.json.arrayValue.map { PipedAPI.extractVideo(from: $0)! }
|
||||
}
|
||||
|
||||
if account.token.isNil {
|
||||
updateToken()
|
||||
}
|
||||
}
|
||||
|
||||
func needsAuthorization(_ url: URL) -> Bool {
|
||||
PipedAPI.authorizedEndpoints.contains { url.absoluteString.contains($0) }
|
||||
}
|
||||
|
||||
@discardableResult func updateToken() -> Request {
|
||||
account.token = nil
|
||||
return login.request(
|
||||
.post,
|
||||
json: ["username": account.username, "password": account.password]
|
||||
)
|
||||
.onSuccess { response in
|
||||
self.account.token = response.json.dictionaryValue["token"]?.string ?? ""
|
||||
self.configure()
|
||||
}
|
||||
}
|
||||
|
||||
var login: Resource {
|
||||
resource(baseURL: account.url, path: "login")
|
||||
}
|
||||
|
||||
func channel(_ id: String) -> Resource {
|
||||
@@ -88,15 +128,34 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
resource(baseURL: account.instance.apiURL, path: "streams/\(id)")
|
||||
}
|
||||
|
||||
var signedIn: Bool { false }
|
||||
var signedIn: Bool {
|
||||
!account.anonymous && !(account.token?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
var subscriptions: Resource? {
|
||||
resource(baseURL: account.instance.apiURL, path: "subscriptions")
|
||||
}
|
||||
|
||||
var feed: Resource? {
|
||||
resource(baseURL: account.instance.apiURL, path: "feed")
|
||||
.withParam("authToken", account.token)
|
||||
}
|
||||
|
||||
var subscriptions: Resource? { nil }
|
||||
var feed: Resource? { nil }
|
||||
var home: Resource? { nil }
|
||||
var popular: Resource? { nil }
|
||||
var playlists: Resource? { nil }
|
||||
|
||||
func channelSubscription(_: String) -> Resource? { nil }
|
||||
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
||||
resource(baseURL: account.instance.apiURL, path: "subscribe")
|
||||
.request(.post, json: ["channelId": channelID])
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
||||
resource(baseURL: account.instance.apiURL, path: "unsubscribe")
|
||||
.request(.post, json: ["channelId": channelID])
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func playlist(_: String) -> Resource? { nil }
|
||||
func playlistVideo(_: String, _: String) -> Resource? { nil }
|
||||
@@ -211,13 +270,15 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
|
||||
let published = (details["uploadedDate"] ?? details["uploadDate"])?.stringValue ??
|
||||
(details["uploaded"]!.double! / 1000).formattedAsRelativeTime()!
|
||||
|
||||
return Video(
|
||||
videoID: PipedAPI.extractID(from: content),
|
||||
title: details["title"]!.stringValue,
|
||||
author: author,
|
||||
length: details["duration"]!.doubleValue,
|
||||
published: details["uploadedDate"]?.stringValue ?? details["uploadDate"]!.stringValue,
|
||||
published: published,
|
||||
views: details["views"]!.intValue,
|
||||
description: PipedAPI.extractDescription(from: content),
|
||||
channel: Channel(id: channelId, name: author),
|
||||
|
@@ -20,7 +20,8 @@ protocol VideosAPI {
|
||||
var popular: Resource? { get }
|
||||
var playlists: Resource? { get }
|
||||
|
||||
func channelSubscription(_ id: String) -> Resource?
|
||||
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void)
|
||||
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void)
|
||||
|
||||
func playlist(_ id: String) -> Resource?
|
||||
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource?
|
||||
|
@@ -8,7 +8,11 @@ enum VideosApp: String, CaseIterable {
|
||||
}
|
||||
|
||||
var supportsAccounts: Bool {
|
||||
self == .invidious
|
||||
true
|
||||
}
|
||||
|
||||
var accountsUsePassword: Bool {
|
||||
self == .piped
|
||||
}
|
||||
|
||||
var supportsPopular: Bool {
|
||||
|
@@ -28,6 +28,11 @@ final class PlaylistsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
|
||||
guard !resource.isNil else {
|
||||
playlists = []
|
||||
return
|
||||
}
|
||||
|
||||
let request = force ? resource?.load() : resource?.loadIfNeeded()
|
||||
|
||||
guard !request.isNil else {
|
||||
|
@@ -19,11 +19,15 @@ final class SubscriptionsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func subscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
|
||||
performRequest(channelID, method: .post, onSuccess: onSuccess)
|
||||
accounts.api.subscribe(channelID) {
|
||||
self.scheduleLoad(onSuccess: onSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
func unsubscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
|
||||
performRequest(channelID, method: .delete, onSuccess: onSuccess)
|
||||
accounts.api.unsubscribe(channelID) {
|
||||
self.scheduleLoad(onSuccess: onSuccess)
|
||||
}
|
||||
}
|
||||
|
||||
func isSubscribing(_ channelID: String) -> Bool {
|
||||
@@ -31,6 +35,9 @@ final class SubscriptionsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
|
||||
guard accounts.app.supportsSubscriptions else {
|
||||
return
|
||||
}
|
||||
let request = force ? resource?.load() : resource?.loadIfNeeded()
|
||||
|
||||
request?
|
||||
@@ -45,8 +52,8 @@ final class SubscriptionsModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func performRequest(_ channelID: String, method: RequestMethod, onSuccess: @escaping () -> Void = {}) {
|
||||
accounts.api.channelSubscription(channelID)?.request(method).onCompletion { _ in
|
||||
private func scheduleLoad(onSuccess: @escaping () -> Void) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
self.load(force: true, onSuccess: onSuccess)
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user