mirror of
https://github.com/yattee/yattee.git
synced 2025-08-06 18:54:11 +00:00
Extended Piped support
This commit is contained in:
79
Model/Account.swift
Normal file
79
Model/Account.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct Account: Defaults.Serializable, Hashable, Identifiable {
|
||||
struct AccountsBridge: Defaults.Bridge {
|
||||
typealias Value = Account
|
||||
typealias Serializable = [String: String]
|
||||
|
||||
func serialize(_ value: Value?) -> Serializable? {
|
||||
guard let value = value else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return [
|
||||
"id": value.id,
|
||||
"instanceID": value.instanceID,
|
||||
"name": value.name ?? "",
|
||||
"url": value.url,
|
||||
"sid": value.sid
|
||||
]
|
||||
}
|
||||
|
||||
func deserialize(_ object: Serializable?) -> Value? {
|
||||
guard
|
||||
let object = object,
|
||||
let id = object["id"],
|
||||
let instanceID = object["instanceID"],
|
||||
let url = object["url"],
|
||||
let sid = object["sid"]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let name = object["name"] ?? ""
|
||||
|
||||
return Account(id: id, instanceID: instanceID, name: name, url: url, sid: sid)
|
||||
}
|
||||
}
|
||||
|
||||
static var bridge = AccountsBridge()
|
||||
|
||||
let id: String
|
||||
let instanceID: String
|
||||
var name: String?
|
||||
let url: String
|
||||
let sid: String
|
||||
let anonymous: Bool
|
||||
|
||||
init(id: String? = nil, instanceID: String? = nil, name: String? = nil, url: String? = nil, sid: String? = nil, anonymous: Bool = false) {
|
||||
self.anonymous = anonymous
|
||||
|
||||
self.id = id ?? (anonymous ? "anonymous-\(instanceID!)" : UUID().uuidString)
|
||||
self.instanceID = instanceID ?? UUID().uuidString
|
||||
self.name = name
|
||||
self.url = url ?? ""
|
||||
self.sid = sid ?? ""
|
||||
}
|
||||
|
||||
var instance: Instance {
|
||||
Defaults[.instances].first { $0.id == instanceID }!
|
||||
}
|
||||
|
||||
var anonymizedSID: String {
|
||||
guard sid.count > 3 else {
|
||||
return ""
|
||||
}
|
||||
|
||||
let index = sid.index(sid.startIndex, offsetBy: 4)
|
||||
return String(sid[..<index])
|
||||
}
|
||||
|
||||
var description: String {
|
||||
(name != nil && name!.isEmpty) ? "Unnamed (\(anonymizedSID))" : name!
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(sid)
|
||||
}
|
||||
}
|
@@ -3,9 +3,9 @@ import Siesta
|
||||
import SwiftUI
|
||||
|
||||
final class AccountValidator: Service {
|
||||
let app: Binding<Instance.App>
|
||||
let app: Binding<VideosApp>
|
||||
let url: String
|
||||
let account: Instance.Account?
|
||||
let account: Account?
|
||||
|
||||
var formObjectID: Binding<String>
|
||||
var isValid: Binding<Bool>
|
||||
@@ -14,9 +14,9 @@ final class AccountValidator: Service {
|
||||
var error: Binding<String?>?
|
||||
|
||||
init(
|
||||
app: Binding<Instance.App>,
|
||||
app: Binding<VideosApp>,
|
||||
url: String,
|
||||
account: Instance.Account? = nil,
|
||||
account: Account? = nil,
|
||||
id: Binding<String>,
|
||||
isValid: Binding<Bool>,
|
||||
isValidated: Binding<Bool>,
|
||||
|
@@ -3,18 +3,18 @@ import Defaults
|
||||
import Foundation
|
||||
|
||||
final class AccountsModel: ObservableObject {
|
||||
@Published private(set) var current: Instance.Account!
|
||||
@Published private(set) var current: Account!
|
||||
|
||||
@Published private(set) var invidious = InvidiousAPI()
|
||||
@Published private(set) var piped = PipedAPI()
|
||||
@Published private var invidious = InvidiousAPI()
|
||||
@Published private var piped = PipedAPI()
|
||||
|
||||
private var cancellables = [AnyCancellable]()
|
||||
|
||||
var all: [Instance.Account] {
|
||||
var all: [Account] {
|
||||
Defaults[.accounts]
|
||||
}
|
||||
|
||||
var lastUsed: Instance.Account? {
|
||||
var lastUsed: Account? {
|
||||
guard let id = Defaults[.lastAccountID] else {
|
||||
return nil
|
||||
}
|
||||
@@ -22,6 +22,14 @@ final class AccountsModel: ObservableObject {
|
||||
return AccountsModel.find(id)
|
||||
}
|
||||
|
||||
var app: VideosApp {
|
||||
current?.instance.app ?? .invidious
|
||||
}
|
||||
|
||||
var api: VideosAPI {
|
||||
app == .piped ? piped : invidious
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
current.isNil
|
||||
}
|
||||
@@ -40,7 +48,7 @@ final class AccountsModel: ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
func setCurrent(_ account: Instance.Account! = nil) {
|
||||
func setCurrent(_ account: Account! = nil) {
|
||||
guard account != current else {
|
||||
return
|
||||
}
|
||||
@@ -62,18 +70,18 @@ final class AccountsModel: ObservableObject {
|
||||
Defaults[.lastInstanceID] = account.instanceID
|
||||
}
|
||||
|
||||
static func find(_ id: Instance.Account.ID) -> Instance.Account? {
|
||||
static func find(_ id: Account.ID) -> Account? {
|
||||
Defaults[.accounts].first { $0.id == id }
|
||||
}
|
||||
|
||||
static func add(instance: Instance, name: String, sid: String) -> Instance.Account {
|
||||
let account = Instance.Account(instanceID: instance.id, name: name, url: instance.url, sid: sid)
|
||||
static func add(instance: Instance, name: String, sid: String) -> Account {
|
||||
let account = Account(instanceID: instance.id, name: name, url: instance.url, sid: sid)
|
||||
Defaults[.accounts].append(account)
|
||||
|
||||
return account
|
||||
}
|
||||
|
||||
static func remove(_ account: Instance.Account) {
|
||||
static func remove(_ account: Account) {
|
||||
if let accountIndex = Defaults[.accounts].firstIndex(where: { $0.id == account.id }) {
|
||||
Defaults[.accounts].remove(at: accountIndex)
|
||||
}
|
||||
|
@@ -2,125 +2,6 @@ import Defaults
|
||||
import Foundation
|
||||
|
||||
struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
||||
enum App: String, CaseIterable {
|
||||
case invidious, piped
|
||||
|
||||
var name: String {
|
||||
rawValue.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
struct Account: Defaults.Serializable, Hashable, Identifiable {
|
||||
static var bridge = AccountsBridge()
|
||||
|
||||
let id: String
|
||||
let instanceID: String
|
||||
var name: String?
|
||||
let url: String
|
||||
let sid: String
|
||||
let anonymous: Bool
|
||||
|
||||
init(id: String? = nil, instanceID: String? = nil, name: String? = nil, url: String? = nil, sid: String? = nil, anonymous: Bool = false) {
|
||||
self.anonymous = anonymous
|
||||
|
||||
self.id = id ?? (anonymous ? "anonymous-\(instanceID!)" : UUID().uuidString)
|
||||
self.instanceID = instanceID ?? UUID().uuidString
|
||||
self.name = name
|
||||
self.url = url ?? ""
|
||||
self.sid = sid ?? ""
|
||||
}
|
||||
|
||||
var instance: Instance {
|
||||
Defaults[.instances].first { $0.id == instanceID }!
|
||||
}
|
||||
|
||||
var anonymizedSID: String {
|
||||
guard sid.count > 3 else {
|
||||
return ""
|
||||
}
|
||||
|
||||
let index = sid.index(sid.startIndex, offsetBy: 4)
|
||||
return String(sid[..<index])
|
||||
}
|
||||
|
||||
var description: String {
|
||||
(name != nil && name!.isEmpty) ? "Unnamed (\(anonymizedSID))" : name!
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(sid)
|
||||
}
|
||||
|
||||
struct AccountsBridge: Defaults.Bridge {
|
||||
typealias Value = Account
|
||||
typealias Serializable = [String: String]
|
||||
|
||||
func serialize(_ value: Value?) -> Serializable? {
|
||||
guard let value = value else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return [
|
||||
"id": value.id,
|
||||
"instanceID": value.instanceID,
|
||||
"name": value.name ?? "",
|
||||
"url": value.url,
|
||||
"sid": value.sid
|
||||
]
|
||||
}
|
||||
|
||||
func deserialize(_ object: Serializable?) -> Value? {
|
||||
guard
|
||||
let object = object,
|
||||
let id = object["id"],
|
||||
let instanceID = object["instanceID"],
|
||||
let url = object["url"],
|
||||
let sid = object["sid"]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let name = object["name"] ?? ""
|
||||
|
||||
return Account(id: id, instanceID: instanceID, name: name, url: url, sid: sid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static var bridge = InstancesBridge()
|
||||
|
||||
let app: App
|
||||
let id: String
|
||||
let name: String
|
||||
let url: String
|
||||
|
||||
init(app: App, id: String? = nil, name: String, url: String) {
|
||||
self.app = app
|
||||
self.id = id ?? UUID().uuidString
|
||||
self.name = name
|
||||
self.url = url
|
||||
}
|
||||
|
||||
var description: String {
|
||||
"\(app.name) - \(shortDescription)"
|
||||
}
|
||||
|
||||
var longDescription: String {
|
||||
name.isEmpty ? "\(app.name) - \(url)" : "\(app.name) - \(name) (\(url))"
|
||||
}
|
||||
|
||||
var shortDescription: String {
|
||||
name.isEmpty ? url : name
|
||||
}
|
||||
|
||||
var supportsAccounts: Bool {
|
||||
app == .invidious
|
||||
}
|
||||
|
||||
var anonymousAccount: Account {
|
||||
Account(instanceID: id, name: "Anonymous", url: url, sid: "", anonymous: true)
|
||||
}
|
||||
|
||||
struct InstancesBridge: Defaults.Bridge {
|
||||
typealias Value = Instance
|
||||
typealias Serializable = [String: String]
|
||||
@@ -141,7 +22,7 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
||||
func deserialize(_ object: Serializable?) -> Value? {
|
||||
guard
|
||||
let object = object,
|
||||
let app = App(rawValue: object["app"] ?? ""),
|
||||
let app = VideosApp(rawValue: object["app"] ?? ""),
|
||||
let id = object["id"],
|
||||
let url = object["url"]
|
||||
else {
|
||||
@@ -154,6 +35,45 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
static var bridge = InstancesBridge()
|
||||
|
||||
let app: VideosApp
|
||||
let id: String
|
||||
let name: String
|
||||
let url: String
|
||||
|
||||
init(app: VideosApp, id: String? = nil, name: String, url: String) {
|
||||
self.app = app
|
||||
self.id = id ?? UUID().uuidString
|
||||
self.name = name
|
||||
self.url = url
|
||||
}
|
||||
|
||||
var anonymous: VideosAPI {
|
||||
switch app {
|
||||
case .invidious:
|
||||
return InvidiousAPI(account: anonymousAccount)
|
||||
case .piped:
|
||||
return PipedAPI(account: anonymousAccount)
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
"\(app.name) - \(shortDescription)"
|
||||
}
|
||||
|
||||
var longDescription: String {
|
||||
name.isEmpty ? "\(app.name) - \(url)" : "\(app.name) - \(name) (\(url))"
|
||||
}
|
||||
|
||||
var shortDescription: String {
|
||||
name.isEmpty ? url : name
|
||||
}
|
||||
|
||||
var anonymousAccount: Account {
|
||||
Account(instanceID: id, name: "Anonymous", url: url, anonymous: true)
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(url)
|
||||
}
|
||||
|
@@ -22,11 +22,11 @@ final class InstancesModel: ObservableObject {
|
||||
return Defaults[.instances].first { $0.id == id }
|
||||
}
|
||||
|
||||
static func accounts(_ id: Instance.ID?) -> [Instance.Account] {
|
||||
static func accounts(_ id: Instance.ID?) -> [Account] {
|
||||
Defaults[.accounts].filter { $0.instanceID == id }
|
||||
}
|
||||
|
||||
static func add(app: Instance.App, name: String, url: String) -> Instance {
|
||||
static func add(app: VideosApp, name: String, url: String) -> Instance {
|
||||
let instance = Instance(app: app, id: UUID().uuidString, name: name, url: url)
|
||||
Defaults[.instances].append(instance)
|
||||
|
||||
@@ -41,7 +41,7 @@ final class InstancesModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
static func setLastAccount(_ account: Instance.Account?) {
|
||||
static func setLastAccount(_ account: Account?) {
|
||||
Defaults[.lastAccountID] = account?.id
|
||||
}
|
||||
}
|
||||
|
@@ -3,15 +3,15 @@ import Foundation
|
||||
import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
final class InvidiousAPI: Service, ObservableObject {
|
||||
final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
static let basePath = "/api/v1"
|
||||
|
||||
@Published var account: Instance.Account!
|
||||
@Published var account: Account!
|
||||
|
||||
@Published var validInstance = true
|
||||
@Published var signedIn = false
|
||||
|
||||
init(account: Instance.Account? = nil) {
|
||||
init(account: Account? = nil) {
|
||||
super.init()
|
||||
|
||||
guard !account.isNil else {
|
||||
@@ -22,7 +22,7 @@ final class InvidiousAPI: Service, ObservableObject {
|
||||
setAccount(account!)
|
||||
}
|
||||
|
||||
func setAccount(_ account: Instance.Account) {
|
||||
func setAccount(_ account: Account) {
|
||||
self.account = account
|
||||
|
||||
validInstance = false
|
||||
@@ -42,7 +42,7 @@ final class InvidiousAPI: Service, ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
home
|
||||
home?
|
||||
.load()
|
||||
.onSuccess { _ in
|
||||
self.validInstance = true
|
||||
@@ -57,7 +57,7 @@ final class InvidiousAPI: Service, ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
feed
|
||||
feed?
|
||||
.load()
|
||||
.onSuccess { _ in
|
||||
self.signedIn = true
|
||||
@@ -149,29 +149,29 @@ final class InvidiousAPI: Service, ObservableObject {
|
||||
"SID=\(account.sid)"
|
||||
}
|
||||
|
||||
var popular: Resource {
|
||||
var popular: Resource? {
|
||||
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/popular")
|
||||
}
|
||||
|
||||
func trending(category: TrendingCategory, country: Country) -> Resource {
|
||||
func trending(country: Country, category: TrendingCategory?) -> Resource {
|
||||
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/trending")
|
||||
.withParam("type", category.name)
|
||||
.withParam("type", category!.name)
|
||||
.withParam("region", country.rawValue)
|
||||
}
|
||||
|
||||
var home: Resource {
|
||||
var home: Resource? {
|
||||
resource(baseURL: account.url, path: "/feed/subscriptions")
|
||||
}
|
||||
|
||||
var feed: Resource {
|
||||
var feed: Resource? {
|
||||
resource(baseURL: account.url, path: "\(InvidiousAPI.basePath)/auth/feed")
|
||||
}
|
||||
|
||||
var subscriptions: Resource {
|
||||
var subscriptions: Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
}
|
||||
|
||||
func channelSubscription(_ id: String) -> Resource {
|
||||
func channelSubscription(_ id: String) -> Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions")).child(id)
|
||||
}
|
||||
|
||||
@@ -187,20 +187,20 @@ final class InvidiousAPI: Service, ObservableObject {
|
||||
resource(baseURL: account.url, path: basePathAppending("videos/\(id)"))
|
||||
}
|
||||
|
||||
var playlists: Resource {
|
||||
var playlists: Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
|
||||
}
|
||||
|
||||
func playlist(_ id: String) -> Resource {
|
||||
func playlist(_ id: String) -> Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/playlists/\(id)"))
|
||||
}
|
||||
|
||||
func playlistVideos(_ id: String) -> Resource {
|
||||
playlist(id).child("videos")
|
||||
func playlistVideos(_ id: String) -> Resource? {
|
||||
playlist(id)?.child("videos")
|
||||
}
|
||||
|
||||
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource {
|
||||
playlist(playlistID).child("videos").child(videoID)
|
||||
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource? {
|
||||
playlist(playlistID)?.child("videos").child(videoID)
|
||||
}
|
||||
|
||||
func search(_ query: SearchQuery) -> Resource {
|
||||
|
@@ -3,14 +3,14 @@ import Foundation
|
||||
import Siesta
|
||||
import SwiftyJSON
|
||||
|
||||
final class PipedAPI: Service, ObservableObject {
|
||||
@Published var account: Instance.Account!
|
||||
final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
@Published var account: Account!
|
||||
|
||||
var anonymousAccount: Instance.Account {
|
||||
var anonymousAccount: Account {
|
||||
.init(instanceID: account.instance.id, name: "Anonymous", url: account.instance.url)
|
||||
}
|
||||
|
||||
init(account: Instance.Account? = nil) {
|
||||
init(account: Account? = nil) {
|
||||
super.init()
|
||||
|
||||
guard account != nil else {
|
||||
@@ -20,7 +20,7 @@ final class PipedAPI: Service, ObservableObject {
|
||||
setAccount(account!)
|
||||
}
|
||||
|
||||
func setAccount(_ account: Instance.Account) {
|
||||
func setAccount(_ account: Account) {
|
||||
self.account = account
|
||||
|
||||
configure()
|
||||
@@ -31,15 +31,128 @@ final class PipedAPI: Service, ObservableObject {
|
||||
$0.pipeline[.parsing].add(SwiftyJSONTransformer, contentTypes: ["*/json"])
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("streams/*"), requestMethods: [.get]) { (content: Entity<JSON>) -> [Stream] in
|
||||
self.extractStreams(content)
|
||||
configureTransformer(pathPattern("channel/*")) { (content: Entity<JSON>) -> Channel? in
|
||||
self.extractChannel(content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("streams/*")) { (content: Entity<JSON>) -> Video? in
|
||||
self.extractVideo(content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("trending")) { (content: Entity<JSON>) -> [Video] in
|
||||
self.extractVideos(content.json)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("search")) { (content: Entity<JSON>) -> [Video] in
|
||||
self.extractVideos(content.json.dictionaryValue["items"]!)
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("suggestions")) { (content: Entity<JSON>) -> [String] in
|
||||
content.json.arrayValue.map(String.init)
|
||||
}
|
||||
}
|
||||
|
||||
private func extractStreams(_ content: Entity<JSON>) -> [Stream] {
|
||||
private func extractChannel(_ content: JSON) -> Channel? {
|
||||
Channel(
|
||||
id: content.dictionaryValue["id"]!.stringValue,
|
||||
name: content.dictionaryValue["name"]!.stringValue,
|
||||
subscriptionsCount: content.dictionaryValue["subscriberCount"]!.intValue,
|
||||
videos: extractVideos(content.dictionaryValue["relatedStreams"]!)
|
||||
)
|
||||
}
|
||||
|
||||
private func extractVideo(_ content: JSON) -> Video? {
|
||||
let details = content.dictionaryValue
|
||||
let url = details["url"]?.string
|
||||
|
||||
if !url.isNil {
|
||||
guard url!.contains("/watch") else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
let channelId = details["uploaderUrl"]!.stringValue.components(separatedBy: "/").last!
|
||||
|
||||
let thumbnails: [Thumbnail] = Thumbnail.Quality.allCases.compactMap {
|
||||
if let url = buildThumbnailURL(content, quality: $0) {
|
||||
return Thumbnail(url: url, quality: $0)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
let author = details["uploaderName"]?.stringValue ?? details["uploader"]!.stringValue
|
||||
|
||||
return Video(
|
||||
videoID: extractID(content),
|
||||
title: details["title"]!.stringValue,
|
||||
author: author,
|
||||
length: details["duration"]!.doubleValue,
|
||||
published: details["uploadedDate"]?.stringValue ?? details["uploadDate"]!.stringValue,
|
||||
views: details["views"]!.intValue,
|
||||
description: extractDescription(content),
|
||||
channel: Channel(id: channelId, name: author),
|
||||
thumbnails: thumbnails,
|
||||
likes: details["likes"]?.int,
|
||||
dislikes: details["dislikes"]?.int,
|
||||
streams: extractStreams(content)
|
||||
)
|
||||
}
|
||||
|
||||
private func extractID(_ content: JSON) -> Video.ID {
|
||||
content.dictionaryValue["url"]?.stringValue.components(separatedBy: "?v=").last ??
|
||||
extractThumbnailURL(content)!.relativeString.components(separatedBy: "/")[4]
|
||||
}
|
||||
|
||||
private func extractThumbnailURL(_ content: JSON) -> URL? {
|
||||
content.dictionaryValue["thumbnail"]?.url! ?? content.dictionaryValue["thumbnailUrl"]!.url!
|
||||
}
|
||||
|
||||
private func buildThumbnailURL(_ content: JSON, quality: Thumbnail.Quality) -> URL? {
|
||||
let thumbnailURL = extractThumbnailURL(content)
|
||||
guard !thumbnailURL.isNil else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return URL(string: thumbnailURL!
|
||||
.absoluteString
|
||||
.replacingOccurrences(of: "_webp", with: "")
|
||||
.replacingOccurrences(of: ".webp", with: ".jpg")
|
||||
.replacingOccurrences(of: "hqdefault", with: quality.filename)
|
||||
.replacingOccurrences(of: "maxresdefault", with: quality.filename)
|
||||
)!
|
||||
}
|
||||
|
||||
private func extractDescription(_ content: JSON) -> String? {
|
||||
guard var description = content.dictionaryValue["description"]?.string else {
|
||||
return nil
|
||||
}
|
||||
|
||||
description = description.replacingOccurrences(
|
||||
of: "<br/>|<br />|<br>",
|
||||
with: "\n",
|
||||
options: .regularExpression,
|
||||
range: nil
|
||||
)
|
||||
|
||||
description = description.replacingOccurrences(
|
||||
of: "<[^>]+>",
|
||||
with: "",
|
||||
options: .regularExpression,
|
||||
range: nil
|
||||
)
|
||||
|
||||
return description
|
||||
}
|
||||
|
||||
private func extractVideos(_ content: JSON) -> [Video] {
|
||||
content.arrayValue.compactMap(extractVideo(_:))
|
||||
}
|
||||
|
||||
private func extractStreams(_ content: JSON) -> [Stream] {
|
||||
var streams = [Stream]()
|
||||
|
||||
if let hlsURL = content.json.dictionaryValue["hls"]?.url {
|
||||
if let hlsURL = content.dictionaryValue["hls"]?.url {
|
||||
streams.append(Stream(hlsURL: hlsURL))
|
||||
}
|
||||
|
||||
@@ -70,9 +183,8 @@ final class PipedAPI: Service, ObservableObject {
|
||||
return streams
|
||||
}
|
||||
|
||||
private func compatibleAudioStreams(_ content: Entity<JSON>) -> [JSON] {
|
||||
private func compatibleAudioStreams(_ content: JSON) -> [JSON] {
|
||||
content
|
||||
.json
|
||||
.dictionaryValue["audioStreams"]?
|
||||
.arrayValue
|
||||
.filter { $0.dictionaryValue["format"]?.stringValue == "M4A" }
|
||||
@@ -81,19 +193,51 @@ final class PipedAPI: Service, ObservableObject {
|
||||
} ?? []
|
||||
}
|
||||
|
||||
private func compatibleVideoStream(_ content: Entity<JSON>) -> [JSON] {
|
||||
private func compatibleVideoStream(_ content: JSON) -> [JSON] {
|
||||
content
|
||||
.json
|
||||
.dictionaryValue["videoStreams"]?
|
||||
.arrayValue
|
||||
.filter { $0.dictionaryValue["format"] == "MPEG_4" } ?? []
|
||||
}
|
||||
|
||||
func channel(_ id: String) -> Resource {
|
||||
resource(baseURL: account.url, path: "channel/\(id)")
|
||||
}
|
||||
|
||||
func trending(country: Country, category _: TrendingCategory? = nil) -> Resource {
|
||||
resource(baseURL: account.instance.url, path: "trending")
|
||||
.withParam("region", country.rawValue)
|
||||
}
|
||||
|
||||
func search(_ query: SearchQuery) -> Resource {
|
||||
resource(baseURL: account.instance.url, path: "search")
|
||||
.withParam("q", query.query)
|
||||
.withParam("filter", "")
|
||||
}
|
||||
|
||||
func searchSuggestions(query: String) -> Resource {
|
||||
resource(baseURL: account.instance.url, path: "suggestions")
|
||||
.withParam("query", query.lowercased())
|
||||
}
|
||||
|
||||
func video(_ id: Video.ID) -> Resource {
|
||||
resource(baseURL: account.instance.url, path: "streams/\(id)")
|
||||
}
|
||||
|
||||
var signedIn: Bool { false }
|
||||
|
||||
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 playlistVideo(_: String, _: String) -> Resource? { nil }
|
||||
func playlistVideos(_: String) -> Resource? { nil }
|
||||
|
||||
private func pathPattern(_ path: String) -> String {
|
||||
"**\(path)"
|
||||
}
|
||||
|
||||
func streams(id: Video.ID) -> Resource {
|
||||
resource(baseURL: account.instance.url, path: "streams/\(id)")
|
||||
}
|
||||
}
|
||||
|
@@ -226,8 +226,8 @@ final class PlayerModel: ObservableObject {
|
||||
#if !os(macOS)
|
||||
var externalMetadata = [
|
||||
makeMetadataItem(.commonIdentifierTitle, value: video.title),
|
||||
makeMetadataItem(.quickTimeMetadataGenre, value: video.genre),
|
||||
makeMetadataItem(.commonIdentifierDescription, value: video.description)
|
||||
makeMetadataItem(.quickTimeMetadataGenre, value: video.genre ?? ""),
|
||||
makeMetadataItem(.commonIdentifierDescription, value: video.description ?? "")
|
||||
]
|
||||
if let thumbnailData = try? Data(contentsOf: video.thumbnailURL(quality: .medium)!),
|
||||
let image = UIImage(data: thumbnailData),
|
||||
|
@@ -104,22 +104,12 @@ extension PlayerModel {
|
||||
return item
|
||||
}
|
||||
|
||||
func videoResource(_ id: Video.ID) -> Resource {
|
||||
accounts.invidious.video(id)
|
||||
}
|
||||
|
||||
private func loadDetails(_ video: Video?, onSuccess: @escaping (Video) -> Void) {
|
||||
guard video != nil else {
|
||||
return
|
||||
}
|
||||
|
||||
if !video!.streams.isEmpty {
|
||||
logger.critical("not loading video details again")
|
||||
onSuccess(video!)
|
||||
return
|
||||
}
|
||||
|
||||
videoResource(video!.videoID).load().onSuccess { response in
|
||||
accounts.api.video(video!.videoID).load().onSuccess { response in
|
||||
if let video: Video = response.typedContent() {
|
||||
onSuccess(video)
|
||||
}
|
||||
|
@@ -23,57 +23,28 @@ extension PlayerModel {
|
||||
var instancesWithLoadedStreams = [Instance]()
|
||||
|
||||
instances.all.forEach { instance in
|
||||
switch instance.app {
|
||||
case .piped:
|
||||
fetchPipedStreams(instance, video: video) { _ in
|
||||
self.completeIfAllInstancesLoaded(
|
||||
instance: instance,
|
||||
streams: self.availableStreams,
|
||||
instancesWithLoadedStreams: &instancesWithLoadedStreams,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
|
||||
case .invidious:
|
||||
fetchInvidiousStreams(instance, video: video) { _ in
|
||||
self.completeIfAllInstancesLoaded(
|
||||
instance: instance,
|
||||
streams: self.availableStreams,
|
||||
instancesWithLoadedStreams: &instancesWithLoadedStreams,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
fetchStreams(instance.anonymous.video(video.videoID), instance: instance, video: video) { _ in
|
||||
self.completeIfAllInstancesLoaded(
|
||||
instance: instance,
|
||||
streams: self.availableStreams,
|
||||
instancesWithLoadedStreams: &instancesWithLoadedStreams,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchInvidiousStreams(
|
||||
_ instance: Instance,
|
||||
private func fetchStreams(
|
||||
_ resource: Resource,
|
||||
instance: Instance,
|
||||
video: Video,
|
||||
onCompletion: @escaping (ResponseInfo) -> Void = { _ in }
|
||||
) {
|
||||
invidious(instance)
|
||||
.video(video.videoID)
|
||||
resource
|
||||
.load()
|
||||
.onSuccess { response in
|
||||
if let video: Video = response.typedContent() {
|
||||
self.availableStreams += self.streamsWithAssetsFromInstance(instance: instance, streams: video.streams)
|
||||
}
|
||||
}
|
||||
.onCompletion(onCompletion)
|
||||
}
|
||||
|
||||
private func fetchPipedStreams(
|
||||
_ instance: Instance,
|
||||
video: Video,
|
||||
onCompletion: @escaping (ResponseInfo) -> Void = { _ in }
|
||||
) {
|
||||
piped(instance)
|
||||
.streams(id: video.videoID)
|
||||
.load()
|
||||
.onSuccess { response in
|
||||
if let pipedStreams: [Stream] = response.typedContent() {
|
||||
self.availableStreams += self.streamsWithInstance(instance: instance, streams: pipedStreams)
|
||||
self.availableStreams += self.streamsWithInstance(instance: instance, streams: video.streams)
|
||||
}
|
||||
}
|
||||
.onCompletion(onCompletion)
|
||||
|
@@ -9,10 +9,6 @@ final class PlaylistsModel: ObservableObject {
|
||||
|
||||
var accounts = AccountsModel()
|
||||
|
||||
var api: InvidiousAPI {
|
||||
accounts.invidious
|
||||
}
|
||||
|
||||
init(_ playlists: [Playlist] = [Playlist]()) {
|
||||
self.playlists = playlists
|
||||
}
|
||||
@@ -48,19 +44,19 @@ final class PlaylistsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func addVideoToCurrentPlaylist(videoID: Video.ID, onSuccess: @escaping () -> Void = {}) {
|
||||
let resource = api.playlistVideos(currentPlaylist!.id)
|
||||
let resource = accounts.api.playlistVideos(currentPlaylist!.id)
|
||||
let body = ["videoId": videoID]
|
||||
|
||||
resource.request(.post, json: body).onSuccess { _ in
|
||||
resource?.request(.post, json: body).onSuccess { _ in
|
||||
self.load(force: true)
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
func removeVideoFromPlaylist(videoIndexID: String, playlistID: Playlist.ID, onSuccess: @escaping () -> Void = {}) {
|
||||
let resource = api.playlistVideo(playlistID, videoIndexID)
|
||||
let resource = accounts.api.playlistVideo(playlistID, videoIndexID)
|
||||
|
||||
resource.request(.delete).onSuccess { _ in
|
||||
resource?.request(.delete).onSuccess { _ in
|
||||
self.load(force: true)
|
||||
onSuccess()
|
||||
}
|
||||
@@ -71,7 +67,7 @@ final class PlaylistsModel: ObservableObject {
|
||||
}
|
||||
|
||||
private var resource: Resource {
|
||||
api.playlists
|
||||
accounts.api.playlists!
|
||||
}
|
||||
|
||||
private var selectedPlaylist: Playlist? {
|
||||
|
@@ -17,14 +17,10 @@ final class SearchModel: ObservableObject {
|
||||
resource?.isLoading ?? false
|
||||
}
|
||||
|
||||
var api: InvidiousAPI {
|
||||
accounts.invidious
|
||||
}
|
||||
|
||||
func changeQuery(_ changeHandler: @escaping (SearchQuery) -> Void = { _ in }) {
|
||||
changeHandler(query)
|
||||
|
||||
let newResource = api.search(query)
|
||||
let newResource = accounts.api.search(query)
|
||||
guard newResource != previousResource else {
|
||||
return
|
||||
}
|
||||
@@ -43,7 +39,7 @@ final class SearchModel: ObservableObject {
|
||||
func resetQuery(_ query: SearchQuery = SearchQuery()) {
|
||||
self.query = query
|
||||
|
||||
let newResource = api.search(query)
|
||||
let newResource = accounts.api.search(query)
|
||||
guard newResource != previousResource else {
|
||||
return
|
||||
}
|
||||
@@ -87,7 +83,7 @@ final class SearchModel: ObservableObject {
|
||||
suggestionsDebounceTimer?.invalidate()
|
||||
|
||||
suggestionsDebounceTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { _ in
|
||||
let resource = self.api.searchSuggestions(query: query)
|
||||
let resource = self.accounts.api.searchSuggestions(query: query)
|
||||
|
||||
resource.addObserver(self.querySuggestions)
|
||||
resource.loadIfNeeded()
|
||||
|
@@ -6,12 +6,8 @@ final class SubscriptionsModel: ObservableObject {
|
||||
@Published var channels = [Channel]()
|
||||
var accounts: AccountsModel
|
||||
|
||||
var api: InvidiousAPI {
|
||||
accounts.invidious
|
||||
}
|
||||
|
||||
var resource: Resource {
|
||||
api.subscriptions
|
||||
var resource: Resource? {
|
||||
accounts.api.subscriptions
|
||||
}
|
||||
|
||||
init(accounts: AccountsModel? = nil) {
|
||||
@@ -35,7 +31,7 @@ final class SubscriptionsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func load(force: Bool = false, onSuccess: @escaping () -> Void = {}) {
|
||||
let request = force ? resource.load() : resource.loadIfNeeded()
|
||||
let request = force ? resource?.load() : resource?.loadIfNeeded()
|
||||
|
||||
request?
|
||||
.onSuccess { resource in
|
||||
@@ -50,7 +46,7 @@ final class SubscriptionsModel: ObservableObject {
|
||||
}
|
||||
|
||||
fileprivate func performRequest(_ channelID: String, method: RequestMethod, onSuccess: @escaping () -> Void = {}) {
|
||||
api.channelSubscription(channelID).request(method).onCompletion { _ in
|
||||
accounts.api.channelSubscription(channelID)?.request(method).onCompletion { _ in
|
||||
self.load(force: true, onSuccess: onSuccess)
|
||||
}
|
||||
}
|
||||
|
@@ -4,6 +4,29 @@ import SwiftyJSON
|
||||
struct Thumbnail {
|
||||
enum Quality: String, CaseIterable {
|
||||
case maxres, maxresdefault, sddefault, high, medium, `default`, start, middle, end
|
||||
|
||||
var filename: String {
|
||||
switch self {
|
||||
case .maxres:
|
||||
return "maxres"
|
||||
case .maxresdefault:
|
||||
return "maxresdefault"
|
||||
case .sddefault:
|
||||
return "sddefault"
|
||||
case .high:
|
||||
return "hqdefault"
|
||||
case .medium:
|
||||
return "mqdefault"
|
||||
case .default:
|
||||
return "default"
|
||||
case .start:
|
||||
return "1"
|
||||
case .middle:
|
||||
return "2"
|
||||
case .end:
|
||||
return "3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var url: URL
|
||||
|
@@ -12,8 +12,8 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
var length: TimeInterval
|
||||
var published: String
|
||||
var views: Int
|
||||
var description: String
|
||||
var genre: String
|
||||
var description: String?
|
||||
var genre: String?
|
||||
|
||||
// index used when in the Playlist
|
||||
let indexID: String?
|
||||
@@ -38,8 +38,8 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
length: TimeInterval,
|
||||
published: String,
|
||||
views: Int,
|
||||
description: String,
|
||||
genre: String,
|
||||
description: String? = nil,
|
||||
genre: String? = nil,
|
||||
channel: Channel,
|
||||
thumbnails: [Thumbnail] = [],
|
||||
indexID: String? = nil,
|
||||
@@ -48,7 +48,8 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
publishedAt: Date? = nil,
|
||||
likes: Int? = nil,
|
||||
dislikes: Int? = nil,
|
||||
keywords: [String] = []
|
||||
keywords: [String] = [],
|
||||
streams: [Stream] = []
|
||||
) {
|
||||
self.id = id ?? UUID().uuidString
|
||||
self.videoID = videoID
|
||||
@@ -68,6 +69,7 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
self.likes = likes
|
||||
self.dislikes = dislikes
|
||||
self.keywords = keywords
|
||||
self.streams = streams
|
||||
}
|
||||
|
||||
init(_ json: JSON) {
|
||||
@@ -169,7 +171,11 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
}
|
||||
|
||||
func thumbnailURL(quality: Thumbnail.Quality) -> URL? {
|
||||
thumbnails.first { $0.quality == quality }?.url
|
||||
if let url = thumbnails.first(where: { $0.quality == quality })?.url.absoluteString {
|
||||
return URL(string: url.replacingOccurrences(of: "hqdefault", with: quality.filename))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func extractThumbnails(from details: JSON) -> [Thumbnail] {
|
||||
|
24
Model/VideosAPI.swift
Normal file
24
Model/VideosAPI.swift
Normal file
@@ -0,0 +1,24 @@
|
||||
import Foundation
|
||||
import Siesta
|
||||
|
||||
protocol VideosAPI {
|
||||
var signedIn: Bool { get }
|
||||
|
||||
func channel(_ id: String) -> Resource
|
||||
func trending(country: Country, category: TrendingCategory?) -> Resource
|
||||
func search(_ query: SearchQuery) -> Resource
|
||||
func searchSuggestions(query: String) -> Resource
|
||||
|
||||
func video(_ id: Video.ID) -> Resource
|
||||
|
||||
var subscriptions: Resource? { get }
|
||||
var feed: Resource? { get }
|
||||
var home: Resource? { get }
|
||||
var popular: Resource? { get }
|
||||
var playlists: Resource? { get }
|
||||
|
||||
func channelSubscription(_ id: String) -> Resource?
|
||||
|
||||
func playlistVideo(_ playlistID: String, _ videoID: String) -> Resource?
|
||||
func playlistVideos(_ id: String) -> Resource?
|
||||
}
|
33
Model/VideosApp.swift
Normal file
33
Model/VideosApp.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
enum VideosApp: String, CaseIterable {
|
||||
case invidious, piped
|
||||
|
||||
var name: String {
|
||||
rawValue.capitalized
|
||||
}
|
||||
|
||||
var supportsAccounts: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var supportsPopular: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var supportsSearchFilters: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var supportsSubscriptions: Bool {
|
||||
supportsAccounts
|
||||
}
|
||||
|
||||
var supportsTrendingCategories: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
|
||||
var supportsUserPlaylists: Bool {
|
||||
self == .invidious
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user