added Trakt loggin

This commit is contained in:
Francesco 2025-04-13 16:30:21 +02:00
parent d5abd35beb
commit b9cac9c91b
6 changed files with 587 additions and 51 deletions

View file

@ -64,12 +64,25 @@ struct SoraApp: App {
return
}
AniListToken.exchangeAuthorizationCodeForToken(code: code) { success in
if success {
Logger.shared.log("Token exchange successful")
} else {
Logger.shared.log("Token exchange failed", type: "Error")
switch url.host {
case "anilist":
AniListToken.exchangeAuthorizationCodeForToken(code: code) { success in
if success {
Logger.shared.log("AniList token exchange successful")
} else {
Logger.shared.log("AniList token exchange failed", type: "Error")
}
}
case "trakt":
TraktToken.exchangeAuthorizationCodeForToken(code: code) { success in
if success {
Logger.shared.log("Trakt token exchange successful")
} else {
Logger.shared.log("Trakt token exchange failed", type: "Error")
}
}
default:
Logger.shared.log("Unknown authentication service", type: "Error")
}
}
}

View file

@ -0,0 +1,34 @@
//
// Trakt-Login.swift
// Sulfur
//
// Created by Francesco on 13/04/25.
//
import UIKit
class TraktLogin {
static let clientID = "6ec81bf19deb80fdfa25652eef101576ca6aaa0dc016d36079b2de413d71c369"
static let redirectURI = "sora://trakt"
static let authorizationEndpoint = "https://trakt.tv/oauth/authorize"
static func authenticate() {
let urlString = "\(authorizationEndpoint)?client_id=\(clientID)&redirect_uri=\(redirectURI)&response_type=code"
guard let url = URL(string: urlString) else {
return
}
if UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:]) { success in
if success {
Logger.shared.log("Safari opened successfully", type: "Debug")
} else {
Logger.shared.log("Failed to open Safari", type: "Error")
}
}
} else {
Logger.shared.log("Cannot open URL", type: "Error")
}
}
}

View file

@ -0,0 +1,168 @@
//
// Trakt-Token.swift
// Sulfur
//
// Created by Francesco on 13/04/25.
//
import UIKit
import Security
class TraktToken {
static let clientID = "6ec81bf19deb80fdfa25652eef101576ca6aaa0dc016d36079b2de413d71c369"
static let clientSecret = "17cd92f71da3be9d755e2d8a6506fb3c3ecee19a247a6f0120ce2fb1f359850b"
static let redirectURI = "sora://trakt"
static let tokenEndpoint = "https://api.trakt.tv/oauth/token"
static let serviceName = "me.cranci.sora.TraktToken"
static let accessTokenKey = "TraktAccessToken"
static let refreshTokenKey = "TraktRefreshToken"
static let authSuccessNotification = Notification.Name("TraktAuthenticationSuccess")
static let authFailureNotification = Notification.Name("TraktAuthenticationFailure")
private static func saveToKeychain(key: String, data: String) -> Bool {
let tokenData = data.data(using: .utf8)!
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key
]
SecItemDelete(deleteQuery as CFDictionary)
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key,
kSecValueData as String: tokenData
]
return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess
}
static func exchangeAuthorizationCodeForToken(code: String, completion: @escaping (Bool) -> Void) {
guard let url = URL(string: tokenEndpoint) else {
Logger.shared.log("Invalid token endpoint URL", type: "Error")
handleFailure(error: "Invalid token endpoint URL", completion: completion)
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let bodyData: [String: Any] = [
"code": code,
"client_id": clientID,
"client_secret": clientSecret,
"redirect_uri": redirectURI,
"grant_type": "authorization_code"
]
processTokenRequest(request: request, bodyData: bodyData, completion: completion)
}
static func refreshAccessToken(completion: @escaping (Bool) -> Void) {
guard let refreshToken = getRefreshToken() else {
handleFailure(error: "No refresh token available", completion: completion)
return
}
guard let url = URL(string: tokenEndpoint) else {
handleFailure(error: "Invalid token endpoint URL", completion: completion)
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let bodyData: [String: Any] = [
"refresh_token": refreshToken,
"client_id": clientID,
"client_secret": clientSecret,
"redirect_uri": redirectURI,
"grant_type": "refresh_token"
]
processTokenRequest(request: request, bodyData: bodyData, completion: completion)
}
private static func processTokenRequest(request: URLRequest, bodyData: [String: Any], completion: @escaping (Bool) -> Void) {
var request = request
do {
request.httpBody = try JSONSerialization.data(withJSONObject: bodyData)
} catch {
handleFailure(error: "Failed to create request body", completion: completion)
return
}
let task = URLSession.shared.dataTask(with: request) { data, response, error in
DispatchQueue.main.async {
if let error = error {
handleFailure(error: error.localizedDescription, completion: completion)
return
}
guard let data = data else {
handleFailure(error: "No data received", completion: completion)
return
}
do {
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
if let accessToken = json["access_token"] as? String,
let refreshToken = json["refresh_token"] as? String {
let accessSuccess = saveToKeychain(key: accessTokenKey, data: accessToken)
let refreshSuccess = saveToKeychain(key: refreshTokenKey, data: refreshToken)
if accessSuccess && refreshSuccess {
NotificationCenter.default.post(name: authSuccessNotification, object: nil)
completion(true)
} else {
handleFailure(error: "Failed to save tokens to keychain", completion: completion)
}
} else {
let errorMessage = (json["error"] as? String) ?? "Unexpected response"
handleFailure(error: errorMessage, completion: completion)
}
}
} catch {
handleFailure(error: "Failed to parse response: \(error.localizedDescription)", completion: completion)
}
}
}
task.resume()
}
private static func handleFailure(error: String, completion: @escaping (Bool) -> Void) {
Logger.shared.log(error, type: "Error")
NotificationCenter.default.post(name: authFailureNotification, object: nil, userInfo: ["error": error])
completion(false)
}
private static func getRefreshToken() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: refreshTokenKey,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let tokenData = result as? Data,
let token = String(data: tokenData, encoding: .utf8) else {
return nil
}
return token
}
}

View file

@ -0,0 +1,131 @@
//
// TraktPushUpdates.swift
// Sulfur
//
// Created by Francesco on 13/04/25.
//
import UIKit
import Security
class TraktMutation {
let apiURL = URL(string: "https://api.trakt.tv")!
func getTokenFromKeychain() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: TraktToken.serviceName,
kSecAttrAccount as String: TraktToken.accessTokenKey,
kSecReturnData as String: kCFBooleanTrue!,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess,
let tokenData = item as? Data,
let token = String(data: tokenData, encoding: .utf8) else {
return nil
}
return token
}
enum ExternalIDType {
case imdb(String)
case tmdb(Int)
var dictionary: [String: Any] {
switch self {
case .imdb(let id):
return ["imdb": id]
case .tmdb(let id):
return ["tmdb": id]
}
}
}
func markAsWatched(type: String, externalID: ExternalIDType, episodeNumber: Int? = nil, seasonNumber: Int? = nil, completion: @escaping (Result<Void, Error>) -> Void) {
if let sendTraktUpdates = UserDefaults.standard.object(forKey: "sendTraktUpdates") as? Bool,
sendTraktUpdates == false {
return
}
guard let userToken = getTokenFromKeychain() else {
completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Access token not found"])))
return
}
let endpoint = "/sync/history"
let body: [String: Any]
switch type {
case "movie":
body = [
"movies": [
[
"ids": externalID.dictionary
]
]
]
case "episode":
guard let episode = episodeNumber, let season = seasonNumber else {
completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing episode or season number"])))
return
}
body = [
"shows": [
[
"ids": externalID.dictionary,
"seasons": [
[
"number": season,
"episodes": [
["number": episode]
]
]
]
]
]
]
default:
completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid content type"])))
return
}
var request = URLRequest(url: apiURL.appendingPathComponent(endpoint))
request.httpMethod = "POST"
request.setValue("Bearer \(userToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("2", forHTTPHeaderField: "trakt-api-version")
request.setValue(TraktToken.clientID, forHTTPHeaderField: "trakt-api-key")
do {
request.httpBody = try JSONSerialization.data(withJSONObject: body)
} catch {
completion(.failure(error))
return
}
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
completion(.failure(NSError(domain: "", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Unexpected response or status code"])))
return
}
Logger.shared.log("Successfully updated watch status on Trakt", type: "Debug")
completion(.success(()))
}
task.resume()
}
}

View file

@ -11,13 +11,18 @@ import Kingfisher
struct SettingsViewTrackers: View {
@AppStorage("sendPushUpdates") private var isSendPushUpdates = true
@State private var status: String = "You are not logged in"
@State private var isLoggedIn: Bool = false
@State private var username: String = ""
@State private var isLoading: Bool = false
@State private var anilistStatus: String = "You are not logged in"
@State private var isAnilistLoggedIn: Bool = false
@State private var anilistUsername: String = ""
@State private var isAnilistLoading: Bool = false
@State private var profileColor: Color = .accentColor
@AppStorage("sendTraktUpdates") private var isSendTraktUpdates = true
@State private var traktStatus: String = "You are not logged in"
@State private var isTraktLoggedIn: Bool = false
@State private var traktUsername: String = ""
@State private var isTraktLoading: Bool = false
var body: some View {
Form {
Section(header: Text("AniList"), footer: Text("Sora and cranci1 are not affiliated with AniList in any way.\n\nNote that progresses update may not be 100% acurate.")) {
@ -36,31 +41,82 @@ struct SettingsViewTrackers: View {
Text("AniList.co")
.font(.title2)
}
if isLoading {
if isAnilistLoading {
ProgressView()
} else {
if isLoggedIn {
if isAnilistLoggedIn {
HStack(spacing: 0) {
Text("Logged in as ")
Text(username)
Text(anilistUsername)
.foregroundColor(profileColor)
.font(.body)
.fontWeight(.semibold)
}
} else {
Text(status)
Text(anilistStatus)
.multilineTextAlignment(.center)
}
}
if isLoggedIn {
Toggle("Sync progresses", isOn: $isSendPushUpdates)
if isAnilistLoggedIn {
Toggle("Sync anime progress", isOn: $isSendPushUpdates)
.tint(.accentColor)
}
Button(isLoggedIn ? "Log Out from AniList.co" : "Log In with AniList.co") {
if isLoggedIn {
logout()
Button(isAnilistLoggedIn ? "Log Out from AniList" : "Log In with AniList") {
if isAnilistLoggedIn {
logoutAniList()
} else {
login()
loginAniList()
}
}
.font(.body)
}
Section(header: Text("Trakt"), footer: Text("Sora and cranci1 are not affiliated with Trakt in any way.\n\nNote that progress updates may not be 100% accurate.")) {
HStack() {
KFImage(URL(string: "https://static-00.iconduck.com/assets.00/trakt-icon-2048x2048-2633ksxg.png"))
.placeholder {
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
.frame(width: 80, height: 80)
.shimmering()
}
.resizable()
.frame(width: 80, height: 80)
.clipShape(Rectangle())
.cornerRadius(10)
Text("Trakt.tv")
.font(.title2)
}
if isTraktLoading {
ProgressView()
} else {
if isTraktLoggedIn {
HStack(spacing: 0) {
Text("Logged in as ")
Text(traktUsername)
.font(.body)
.fontWeight(.semibold)
}
} else {
Text(traktStatus)
.multilineTextAlignment(.center)
}
}
if isTraktLoggedIn {
Toggle("Sync watch progress", isOn: $isSendTraktUpdates)
.tint(.accentColor)
}
Button(isTraktLoggedIn ? "Log Out from Trakt" : "Log In with Trakt") {
if isTraktLoggedIn {
logoutTrakt()
} else {
loginTrakt()
}
}
.font(.body)
@ -68,7 +124,8 @@ struct SettingsViewTrackers: View {
}
.navigationTitle("Trackers")
.onAppear {
updateStatus()
updateAniListStatus()
updateTraktStatus()
setupNotificationObservers()
}
.onDisappear {
@ -76,54 +133,167 @@ struct SettingsViewTrackers: View {
}
}
func removeNotificationObservers() {
NotificationCenter.default.removeObserver(self, name: AniListToken.authSuccessNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: AniListToken.authFailureNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: TraktToken.authSuccessNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: TraktToken.authFailureNotification, object: nil)
}
func setupNotificationObservers() {
NotificationCenter.default.addObserver(forName: AniListToken.authSuccessNotification, object: nil, queue: .main) { _ in
self.status = "Authentication successful!"
self.updateStatus()
self.anilistStatus = "Authentication successful!"
self.updateAniListStatus()
}
NotificationCenter.default.addObserver(forName: AniListToken.authFailureNotification, object: nil, queue: .main) { notification in
if let error = notification.userInfo?["error"] as? String {
self.status = "Login failed: \(error)"
self.anilistStatus = "Login failed: \(error)"
} else {
self.status = "Login failed with unknown error"
self.anilistStatus = "Login failed with unknown error"
}
self.isLoggedIn = false
self.isLoading = false
self.isAnilistLoggedIn = false
self.isAnilistLoading = false
}
NotificationCenter.default.addObserver(forName: TraktToken.authSuccessNotification, object: nil, queue: .main) { _ in
self.traktStatus = "Authentication successful!"
self.updateTraktStatus()
}
NotificationCenter.default.addObserver(forName: TraktToken.authFailureNotification, object: nil, queue: .main) { notification in
if let error = notification.userInfo?["error"] as? String {
self.traktStatus = "Login failed: \(error)"
} else {
self.traktStatus = "Login failed with unknown error"
}
self.isTraktLoggedIn = false
self.isTraktLoading = false
}
}
func removeNotificationObservers() {
NotificationCenter.default.removeObserver(self, name: AniListToken.authSuccessNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: AniListToken.authFailureNotification, object: nil)
func loginTrakt() {
traktStatus = "Starting authentication..."
isTraktLoading = true
TraktLogin.authenticate()
}
func login() {
status = "Starting authentication..."
isLoading = true
func logoutTrakt() {
removeTraktTokenFromKeychain()
traktStatus = "You are not logged in"
isTraktLoggedIn = false
traktUsername = ""
}
func updateTraktStatus() {
if let token = getTraktTokenFromKeychain() {
isTraktLoggedIn = true
fetchTraktUserInfo(token: token)
} else {
isTraktLoggedIn = false
traktStatus = "You are not logged in"
}
}
func fetchTraktUserInfo(token: String) {
isTraktLoading = true
let userInfoURL = URL(string: "https://api.trakt.tv/users/settings")!
var request = URLRequest(url: userInfoURL)
request.httpMethod = "GET"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("2", forHTTPHeaderField: "trakt-api-version")
request.setValue(TraktToken.clientID, forHTTPHeaderField: "trakt-api-key")
URLSession.shared.dataTask(with: request) { data, response, error in
DispatchQueue.main.async {
self.isTraktLoading = false
if let error = error {
self.traktStatus = "Error: \(error.localizedDescription)"
return
}
guard let data = data else {
self.traktStatus = "No data received"
return
}
do {
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let user = json["user"] as? [String: Any],
let username = user["username"] as? String {
self.traktUsername = username
self.traktStatus = "Logged in as \(username)"
}
} catch {
self.traktStatus = "Failed to parse response"
}
}
}.resume()
}
func getTraktTokenFromKeychain() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: TraktToken.serviceName,
kSecAttrAccount as String: TraktToken.accessTokenKey,
kSecReturnData as String: kCFBooleanTrue!,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess,
let tokenData = item as? Data,
let token = String(data: tokenData, encoding: .utf8) else {
return nil
}
return token
}
func removeTraktTokenFromKeychain() {
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: TraktToken.serviceName,
kSecAttrAccount as String: TraktToken.accessTokenKey
]
SecItemDelete(deleteQuery as CFDictionary)
let refreshDeleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: TraktToken.serviceName,
kSecAttrAccount as String: TraktToken.refreshTokenKey
]
SecItemDelete(refreshDeleteQuery as CFDictionary)
}
func loginAniList() {
anilistStatus = "Starting authentication..."
isAnilistLoading = true
AniListLogin.authenticate()
}
func logout() {
func logoutAniList() {
removeTokenFromKeychain()
status = "You are not logged in"
isLoggedIn = false
username = ""
anilistStatus = "You are not logged in"
isAnilistLoggedIn = false
anilistUsername = ""
profileColor = .primary
}
func updateStatus() {
func updateAniListStatus() {
if let token = getTokenFromKeychain() {
isLoggedIn = true
isAnilistLoggedIn = true
fetchUserInfo(token: token)
} else {
isLoggedIn = false
status = "You are not logged in"
isAnilistLoggedIn = false
anilistStatus = "You are not logged in"
}
}
func fetchUserInfo(token: String) {
isLoading = true
isAnilistLoading = true
let userInfoURL = URL(string: "https://graphql.anilist.co")!
var request = URLRequest(url: userInfoURL)
request.httpMethod = "POST"
@ -146,22 +316,22 @@ struct SettingsViewTrackers: View {
do {
request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
} catch {
status = "Failed to serialize request"
anilistStatus = "Failed to serialize request"
Logger.shared.log("Failed to serialize request", type: "Error")
isLoading = false
isAnilistLoading = false
return
}
URLSession.shared.dataTask(with: request) { data, response, error in
DispatchQueue.main.async {
isLoading = false
isAnilistLoading = false
if let error = error {
status = "Error: \(error.localizedDescription)"
anilistStatus = "Error: \(error.localizedDescription)"
Logger.shared.log("Error: \(error.localizedDescription)", type: "Error")
return
}
guard let data = data else {
status = "No data received"
anilistStatus = "No data received"
Logger.shared.log("No data received", type: "Error")
return
}
@ -173,15 +343,15 @@ struct SettingsViewTrackers: View {
let options = viewer["options"] as? [String: Any],
let colorName = options["profileColor"] as? String {
username = name
anilistUsername = name
profileColor = colorFromName(colorName)
status = "Logged in as \(name)"
anilistStatus = "Logged in as \(name)"
} else {
status = "Unexpected response format!"
anilistStatus = "Unexpected response format!"
Logger.shared.log("Unexpected response format!", type: "Error")
}
} catch {
status = "Failed to parse response: \(error.localizedDescription)"
anilistStatus = "Failed to parse response: \(error.localizedDescription)"
Logger.shared.log("Failed to parse response: \(error.localizedDescription)", type: "Error")
}
}

View file

@ -56,6 +56,9 @@
13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB7CC22D7D99C0004371D3 /* SubtitleSettingsManager.swift */; };
13DB7CEC2D7DED5D004371D3 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB7CEB2D7DED5D004371D3 /* DownloadManager.swift */; };
13DC0C462D302C7500D0F966 /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DC0C452D302C7500D0F966 /* VideoPlayer.swift */; };
13E62FC22DABC5830007E259 /* Trakt-Login.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13E62FC12DABC5830007E259 /* Trakt-Login.swift */; };
13E62FC42DABC58C0007E259 /* Trakt-Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13E62FC32DABC58C0007E259 /* Trakt-Token.swift */; };
13E62FC72DABFE900007E259 /* TraktPushUpdates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13E62FC62DABFE900007E259 /* TraktPushUpdates.swift */; };
13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */; };
13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */; };
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */; };
@ -114,6 +117,9 @@
13DB7CEB2D7DED5D004371D3 /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = "<group>"; };
13DC0C412D2EC9BA00D0F966 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
13DC0C452D302C7500D0F966 /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = "<group>"; };
13E62FC12DABC5830007E259 /* Trakt-Login.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Trakt-Login.swift"; sourceTree = "<group>"; };
13E62FC32DABC58C0007E259 /* Trakt-Token.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Trakt-Token.swift"; sourceTree = "<group>"; };
13E62FC62DABFE900007E259 /* TraktPushUpdates.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TraktPushUpdates.swift; sourceTree = "<group>"; };
13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomPlayer.swift; sourceTree = "<group>"; };
13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = "<group>"; };
13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalPlayer.swift; sourceTree = "<group>"; };
@ -399,6 +405,7 @@
13E62FBF2DABC3A20007E259 /* Trakt */ = {
isa = PBXGroup;
children = (
13E62FC52DABFE810007E259 /* Mutations */,
13E62FC02DABC3A90007E259 /* Auth */,
);
path = Trakt;
@ -407,10 +414,20 @@
13E62FC02DABC3A90007E259 /* Auth */ = {
isa = PBXGroup;
children = (
13E62FC12DABC5830007E259 /* Trakt-Login.swift */,
13E62FC32DABC58C0007E259 /* Trakt-Token.swift */,
);
path = Auth;
sourceTree = "<group>";
};
13E62FC52DABFE810007E259 /* Mutations */ = {
isa = PBXGroup;
children = (
13E62FC62DABFE900007E259 /* TraktPushUpdates.swift */,
);
path = Mutations;
sourceTree = "<group>";
};
13EA2BD02D32D97400C1EBD7 /* CustomPlayer */ = {
isa = PBXGroup;
children = (
@ -549,9 +566,12 @@
133D7C942D2BE2640075467E /* JSController.swift in Sources */,
133D7C922D2BE2640075467E /* URLSession.swift in Sources */,
133D7C912D2BE2640075467E /* SettingsViewModule.swift in Sources */,
13E62FC22DABC5830007E259 /* Trakt-Login.swift in Sources */,
130217CC2D81C55E0011EFF5 /* DownloadView.swift in Sources */,
133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */,
13E62FC42DABC58C0007E259 /* Trakt-Token.swift in Sources */,
133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */,
13E62FC72DABFE900007E259 /* TraktPushUpdates.swift in Sources */,
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */,
138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */,
13DB468D2D90093A008CBC03 /* Anilist-Login.swift in Sources */,