Ferrite: Add bookmarks

Bookmarks are added through search results and can be accessed through
the library. These can be moved and deleted within the list.

Add a RealDebrid instant availability cache for bookmark IA status
to not overwhelm the API. Instant availability results are fresh on
every search results since the cache is cleared.

Also don't require a source API object to be present for the API parser
button in source settings. If a JSON parser exists for a source, allow
the option to be presented.

Signed-off-by: kingbri <bdashore3@proton.me>
This commit is contained in:
kingbri 2022-09-05 18:32:41 -04:00
parent 5d97c7511f
commit 2f870b9410
23 changed files with 635 additions and 154 deletions

View file

@ -16,6 +16,8 @@
0C32FB552890D1BF002BD219 /* UIApplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C32FB542890D1BF002BD219 /* UIApplication.swift */; };
0C32FB572890D1F2002BD219 /* ListRowViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C32FB562890D1F2002BD219 /* ListRowViews.swift */; };
0C360C5C28C7DF1400884ED3 /* DynamicFetchRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C360C5B28C7DF1400884ED3 /* DynamicFetchRequest.swift */; };
0C41BC6328C2AD0F00B47DD6 /* SearchResultButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */; };
0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */; };
0C4CFC462897030D00AD9FAD /* Regex in Frameworks */ = {isa = PBXBuildFile; productRef = 0C4CFC452897030D00AD9FAD /* Regex */; };
0C4CFC4D28970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4CFC4728970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift */; };
0C4CFC4E28970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C4CFC4828970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift */; };
@ -25,6 +27,8 @@
0C64A4B7288903880079976D /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0C64A4B6288903880079976D /* KeychainSwift */; };
0C68135028BC1A2D00FAD890 /* GithubWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */; };
0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C68135128BC1A7C00FAD890 /* GithubModels.swift */; };
0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */; };
0C70E40628C40C4E00A5C72D /* NotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */; };
0C733287289C4C820058D1FE /* SourceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C733286289C4C820058D1FE /* SourceSettingsView.swift */; };
0C7376F028A97D1400D60918 /* SwiftUIX in Frameworks */ = {isa = PBXBuildFile; productRef = 0C7376EF28A97D1400D60918 /* SwiftUIX */; };
0C7506D728B1AC9A008BEE38 /* SwiftyJSON in Frameworks */ = {isa = PBXBuildFile; productRef = 0C7506D628B1AC9A008BEE38 /* SwiftyJSON */; };
@ -70,6 +74,11 @@
0CA148E9288903F000DE2211 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D1288903F000DE2211 /* MainView.swift */; };
0CA148EB288903F000DE2211 /* SearchResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D3288903F000DE2211 /* SearchResultsView.swift */; };
0CA148EC288903F000DE2211 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D4288903F000DE2211 /* ContentView.swift */; };
0CA3B23428C2658700616D3A /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3B23328C2658700616D3A /* LibraryView.swift */; };
0CA3B23728C2660700616D3A /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3B23628C2660700616D3A /* HistoryView.swift */; };
0CA3B23928C2660D00616D3A /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3B23828C2660D00616D3A /* BookmarksView.swift */; };
0CA3B23C28C2AA5600616D3A /* Bookmark+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3B23A28C2AA5600616D3A /* Bookmark+CoreDataClass.swift */; };
0CA3B23D28C2AA5600616D3A /* Bookmark+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3B23B28C2AA5600616D3A /* Bookmark+CoreDataProperties.swift */; };
0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */; };
0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 0CAF1C7A286F5C8600296F86 /* SwiftSoup */; };
0CB6516328C5A57300DCA721 /* ConditionalId.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB6516228C5A57300DCA721 /* ConditionalId.swift */; };
@ -93,12 +102,16 @@
0C32FB542890D1BF002BD219 /* UIApplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplication.swift; sourceTree = "<group>"; };
0C32FB562890D1F2002BD219 /* ListRowViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRowViews.swift; sourceTree = "<group>"; };
0C360C5B28C7DF1400884ED3 /* DynamicFetchRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicFetchRequest.swift; sourceTree = "<group>"; };
0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultButtonView.swift; sourceTree = "<group>"; };
0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModels.swift; sourceTree = "<group>"; };
0C4CFC4728970C8B00AD9FAD /* SourceComplexQuery+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceComplexQuery+CoreDataClass.swift"; sourceTree = "<group>"; };
0C4CFC4828970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceComplexQuery+CoreDataProperties.swift"; sourceTree = "<group>"; };
0C57D4CB289032ED008534E8 /* SearchResultRDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultRDView.swift; sourceTree = "<group>"; };
0C60B1EE28A1A00000E3FD7E /* SearchProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchProgressView.swift; sourceTree = "<group>"; };
0C68134F28BC1A2D00FAD890 /* GithubWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubWrapper.swift; sourceTree = "<group>"; };
0C68135128BC1A7C00FAD890 /* GithubModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubModels.swift; sourceTree = "<group>"; };
0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalContextMenu.swift; sourceTree = "<group>"; };
0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCenter.swift; sourceTree = "<group>"; };
0C733286289C4C820058D1FE /* SourceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSettingsView.swift; sourceTree = "<group>"; };
0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRssParser+CoreDataClass.swift"; sourceTree = "<group>"; };
0C750743289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SourceRssParser+CoreDataProperties.swift"; sourceTree = "<group>"; };
@ -142,6 +155,11 @@
0CA148D1288903F000DE2211 /* MainView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
0CA148D3288903F000DE2211 /* SearchResultsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchResultsView.swift; sourceTree = "<group>"; };
0CA148D4288903F000DE2211 /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
0CA3B23328C2658700616D3A /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
0CA3B23628C2660700616D3A /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
0CA3B23828C2660D00616D3A /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = "<group>"; };
0CA3B23A28C2AA5600616D3A /* Bookmark+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bookmark+CoreDataClass.swift"; sourceTree = "<group>"; };
0CA3B23B28C2AA5600616D3A /* Bookmark+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bookmark+CoreDataProperties.swift"; sourceTree = "<group>"; };
0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndeterminateProgressView.swift; sourceTree = "<group>"; };
0CAF1C68286F5C0E00296F86 /* Ferrite.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ferrite.app; sourceTree = BUILT_PRODUCTS_DIR; };
0CB6516228C5A57300DCA721 /* ConditionalId.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionalId.swift; sourceTree = "<group>"; };
@ -176,6 +194,8 @@
0C0D50DE288DF72D0035ECC8 /* Classes */ = {
isa = PBXGroup;
children = (
0CA3B23A28C2AA5600616D3A /* Bookmark+CoreDataClass.swift */,
0CA3B23B28C2AA5600616D3A /* Bookmark+CoreDataProperties.swift */,
0C31133A28B1ABFA004DCB0D /* SourceJsonParser+CoreDataClass.swift */,
0C31133B28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift */,
0C750742289B003E004B3906 /* SourceRssParser+CoreDataClass.swift */,
@ -201,6 +221,7 @@
0C0D50E4288DFE7F0035ECC8 /* SourceModels.swift */,
0C95D8D928A55BB6005E22B3 /* SettingsModels.swift */,
0C68135128BC1A7C00FAD890 /* GithubModels.swift */,
0C41BC6428C2AEB900B47DD6 /* SearchModels.swift */,
);
path = Models;
sourceTree = "<group>";
@ -258,6 +279,7 @@
0CB6516928C5B4A600DCA721 /* InlineHeader.swift */,
0CDCB91728C662640098B513 /* EmptyInstructionView.swift */,
0C360C5B28C7DF1400884ED3 /* DynamicFetchRequest.swift */,
0C70E40128C3CE9C00A5C72D /* ConditionalContextMenu.swift */,
);
path = CommonViews;
sourceTree = "<group>";
@ -279,6 +301,7 @@
0C32FB542890D1BF002BD219 /* UIApplication.swift */,
0C7D11FD28AA03FE00ED92DB /* View.swift */,
0C78041C28BFB3EA001E8CA3 /* String.swift */,
0C70E40528C40C4E00A5C72D /* NotificationCenter.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -286,6 +309,7 @@
0CA148EE2889061200DE2211 /* Views */ = {
isa = PBXGroup;
children = (
0CA3B23528C265FD00616D3A /* LibraryViews */,
0C794B65289DAC9F00DD1CC8 /* SourceViews */,
0CA148F02889062700DE2211 /* RepresentableViews */,
0CA148C0288903F000DE2211 /* CommonViews */,
@ -301,6 +325,8 @@
0C0D50E6288DFF850035ECC8 /* SourcesView.swift */,
0C32FB522890D19D002BD219 /* AboutView.swift */,
0C60B1EE28A1A00000E3FD7E /* SearchProgressView.swift */,
0CA3B23328C2658700616D3A /* LibraryView.swift */,
0C41BC6228C2AD0F00B47DD6 /* SearchResultButtonView.swift */,
);
path = Views;
sourceTree = "<group>";
@ -334,6 +360,15 @@
path = API;
sourceTree = "<group>";
};
0CA3B23528C265FD00616D3A /* LibraryViews */ = {
isa = PBXGroup;
children = (
0CA3B23828C2660D00616D3A /* BookmarksView.swift */,
0CA3B23628C2660700616D3A /* HistoryView.swift */,
);
path = LibraryViews;
sourceTree = "<group>";
};
0CAF1C5F286F5C0D00296F86 = {
isa = PBXGroup;
children = (
@ -454,8 +489,10 @@
0C60B1EF28A1A00000E3FD7E /* SearchProgressView.swift in Sources */,
0C32FB532890D19D002BD219 /* AboutView.swift in Sources */,
0CB6516328C5A57300DCA721 /* ConditionalId.swift in Sources */,
0C70E40628C40C4E00A5C72D /* NotificationCenter.swift in Sources */,
0C84F4832895BFED0074B7C9 /* Source+CoreDataProperties.swift in Sources */,
0CA148DB288903F000DE2211 /* NavView.swift in Sources */,
0CA3B23C28C2AA5600616D3A /* Bookmark+CoreDataClass.swift in Sources */,
0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */,
0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */,
0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */,
@ -468,7 +505,10 @@
0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */,
0C32FB552890D1BF002BD219 /* UIApplication.swift in Sources */,
0C7D11FE28AA03FE00ED92DB /* View.swift in Sources */,
0CA3B23728C2660700616D3A /* HistoryView.swift in Sources */,
0C70E40228C3CE9C00A5C72D /* ConditionalContextMenu.swift in Sources */,
0C0D50E7288DFF850035ECC8 /* SourcesView.swift in Sources */,
0CA3B23428C2658700616D3A /* LibraryView.swift in Sources */,
0CA148EC288903F000DE2211 /* ContentView.swift in Sources */,
0C95D8D828A55B03005E22B3 /* DefaultActionsPickerViews.swift in Sources */,
0CA148E1288903F000DE2211 /* Collection.swift in Sources */,
@ -479,20 +519,24 @@
0CB6516A28C5B4A600DCA721 /* InlineHeader.swift in Sources */,
0CA148D8288903F000DE2211 /* MagnetChoiceView.swift in Sources */,
0C84F4862895BFED0074B7C9 /* SourceList+CoreDataClass.swift in Sources */,
0C41BC6528C2AEB900B47DD6 /* SearchModels.swift in Sources */,
0C68135028BC1A2D00FAD890 /* GithubWrapper.swift in Sources */,
0C95D8DA28A55BB6005E22B3 /* SettingsModels.swift in Sources */,
0CA148E3288903F000DE2211 /* Task.swift in Sources */,
0CA148E7288903F000DE2211 /* ToastViewModel.swift in Sources */,
0C68135228BC1A7C00FAD890 /* GithubModels.swift in Sources */,
0CFEFCFD288A006200B3F490 /* GroupBoxStyle.swift in Sources */,
0CA3B23928C2660D00616D3A /* BookmarksView.swift in Sources */,
0C79DC072899AF3C003F1C5A /* SourceSeedLeech+CoreDataClass.swift in Sources */,
0C794B67289DACB600DD1CC8 /* SourceUpdateButtonView.swift in Sources */,
0CA148E6288903F000DE2211 /* WebView.swift in Sources */,
0CA3B23D28C2AA5600616D3A /* Bookmark+CoreDataProperties.swift in Sources */,
0C4CFC4E28970C8B00AD9FAD /* SourceComplexQuery+CoreDataProperties.swift in Sources */,
0CDCB91828C662640098B513 /* EmptyInstructionView.swift in Sources */,
0CA148E2288903F000DE2211 /* Data.swift in Sources */,
0C57D4CC289032ED008534E8 /* SearchResultRDView.swift in Sources */,
0C7D11FC28AA01E900ED92DB /* DynamicAccentColor.swift in Sources */,
0C41BC6328C2AD0F00B47DD6 /* SearchResultButtonView.swift in Sources */,
0CA05459288EE9E600850554 /* SourceManager.swift in Sources */,
0C84F4772895BE680074B7C9 /* FerriteDB.xcdatamodeld in Sources */,
0C733287289C4C820058D1FE /* SourceSettingsView.swift in Sources */,

View file

@ -248,9 +248,21 @@ public class RealDebrid {
}
}
availableHashes.append(RealDebridIA(hash: hash, files: files, batches: batches))
// TTL: 5 minutes
availableHashes.append(
RealDebridIA(
hash: hash,
expiryTimeStamp: Date().timeIntervalSince1970 + 300,
files: files,
batches: batches)
)
} else {
availableHashes.append(RealDebridIA(hash: hash))
availableHashes.append(
RealDebridIA(
hash: hash,
expiryTimeStamp: Date().timeIntervalSince1970 + 300
)
)
}
}

View file

@ -0,0 +1,25 @@
//
// Bookmark+CoreDataClass.swift
// Ferrite
//
// Created by Brian Dashore on 9/2/22.
//
//
import CoreData
import Foundation
@objc(Bookmark)
public class Bookmark: NSManagedObject {
func toSearchResult() -> SearchResult {
SearchResult(
title: title,
source: source,
size: size,
magnetLink: magnetLink,
magnetHash: magnetHash,
seeders: seeders,
leechers: leechers
)
}
}

View file

@ -0,0 +1,32 @@
//
// Bookmark+CoreDataProperties.swift
// Ferrite
//
// Created by Brian Dashore on 9/3/22.
//
//
import Foundation
import CoreData
extension Bookmark {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Bookmark> {
return NSFetchRequest<Bookmark>(entityName: "Bookmark")
}
@NSManaged public var leechers: String?
@NSManaged public var magnetHash: String?
@NSManaged public var magnetLink: String?
@NSManaged public var seeders: String?
@NSManaged public var size: String?
@NSManaged public var source: String
@NSManaged public var title: String?
@NSManaged public var orderNum: Int16
}
extension Bookmark : Identifiable {
}

View file

@ -1,5 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21277" systemVersion="21F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Bookmark" representedClassName="Bookmark" syncable="YES">
<attribute name="leechers" optional="YES" attributeType="String"/>
<attribute name="magnetHash" optional="YES" attributeType="String"/>
<attribute name="magnetLink" optional="YES" attributeType="String"/>
<attribute name="orderNum" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="seeders" optional="YES" attributeType="String"/>
<attribute name="size" optional="YES" attributeType="String"/>
<attribute name="source" attributeType="String" defaultValueString=""/>
<attribute name="title" optional="YES" attributeType="String"/>
</entity>
<entity name="History" representedClassName="History" syncable="YES" codeGenerationType="class">
<attribute name="date" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="dateString" optional="YES" attributeType="String"/>
<relationship name="entries" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="HistoryEntry" inverseName="parentHistory" inverseEntity="HistoryEntry"/>
</entity>
<entity name="HistoryEntry" representedClassName="HistoryEntry" syncable="YES" codeGenerationType="class">
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="timeStamp" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="url" optional="YES" attributeType="String"/>
<relationship name="parentHistory" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="History" inverseName="entries" inverseEntity="History"/>
</entity>
<entity name="Source" representedClassName="Source" syncable="YES">
<attribute name="author" attributeType="String" defaultValueString=""/>
<attribute name="baseUrl" optional="YES" attributeType="String"/>

View file

@ -0,0 +1,14 @@
//
// NotificationCenter.swift
// Ferrite
//
// Created by Brian Dashore on 9/3/22.
//
import Foundation
extension Notification.Name {
static var didDeleteBookmark: Notification.Name {
return Notification.Name("Deleted bookmark")
}
}

View file

@ -36,4 +36,11 @@ extension View {
func inlinedList() -> some View {
modifier(InlinedList())
}
func conditionalContextMenu<InternalContent: View, ID: Hashable>(
id: ID,
@ViewBuilder _ internalContent: @escaping () -> InternalContent
) -> some View {
modifier(ConditionalContextMenu(internalContent, id: id))
}
}

View file

@ -11,148 +11,149 @@ import Foundation
// MARK: - device code endpoint
public struct DeviceCodeResponse: Codable {
let deviceCode, userCode: String
let interval, expiresIn: Int
let verificationURL, directVerificationURL: String
let deviceCode, userCode: String
let interval, expiresIn: Int
let verificationURL, directVerificationURL: String
enum CodingKeys: String, CodingKey {
case deviceCode = "device_code"
case userCode = "user_code"
case interval
case expiresIn = "expires_in"
case verificationURL = "verification_url"
case directVerificationURL = "direct_verification_url"
}
enum CodingKeys: String, CodingKey {
case deviceCode = "device_code"
case userCode = "user_code"
case interval
case expiresIn = "expires_in"
case verificationURL = "verification_url"
case directVerificationURL = "direct_verification_url"
}
}
// MARK: - device credentials endpoint
public struct DeviceCredentialsResponse: Codable {
let clientID, clientSecret: String?
let clientID, clientSecret: String?
enum CodingKeys: String, CodingKey {
case clientID = "client_id"
case clientSecret = "client_secret"
}
enum CodingKeys: String, CodingKey {
case clientID = "client_id"
case clientSecret = "client_secret"
}
}
// MARK: - token endpoint
public struct TokenResponse: Codable {
let accessToken: String
let expiresIn: Int
let refreshToken, tokenType: String
let accessToken: String
let expiresIn: Int
let refreshToken, tokenType: String
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case expiresIn = "expires_in"
case refreshToken = "refresh_token"
case tokenType = "token_type"
}
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case expiresIn = "expires_in"
case refreshToken = "refresh_token"
case tokenType = "token_type"
}
}
// MARK: - instantAvailability endpoint
// Thanks Skitty!
public struct InstantAvailabilityResponse: Codable {
var data: InstantAvailabilityData?
var data: InstantAvailabilityData?
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let data = try? container.decode(InstantAvailabilityData.self) {
self.data = data
}
}
if let data = try? container.decode(InstantAvailabilityData.self) {
self.data = data
}
}
}
struct InstantAvailabilityData: Codable {
var rd: [[String: InstantAvailabilityInfo]]
var rd: [[String: InstantAvailabilityInfo]]
}
struct InstantAvailabilityInfo: Codable {
var filename: String
var filesize: Int
var filename: String
var filesize: Int
}
// MARK: - Instant Availability client side structures
public struct RealDebridIA: Codable, Hashable {
let hash: String
var files: [RealDebridIAFile] = []
var batches: [RealDebridIABatch] = []
let hash: String
let expiryTimeStamp: Double
var files: [RealDebridIAFile] = []
var batches: [RealDebridIABatch] = []
}
public struct RealDebridIABatch: Codable, Hashable {
let files: [RealDebridIABatchFile]
let files: [RealDebridIABatchFile]
}
public struct RealDebridIABatchFile: Codable, Hashable {
let id: Int
let fileName: String
let id: Int
let fileName: String
}
public struct RealDebridIAFile: Codable, Hashable {
let name: String
let batchIndex: Int
let batchFileIndex: Int
let name: String
let batchIndex: Int
let batchFileIndex: Int
}
public enum RealDebridIAStatus: Codable, Hashable {
case full
case partial
case none
case full
case partial
case none
}
// MARK: - addMagnet endpoint
public struct AddMagnetResponse: Codable {
let id: String
let uri: String
let id: String
let uri: String
}
// MARK: - torrentInfo endpoint
struct TorrentInfoResponse: Codable {
let id, filename, originalFilename, hash: String
let bytes, originalBytes: Int
let host: String
let split, progress: Int
let status, added: String
let files: [TorrentInfoFile]
let links: [String]
let ended: String
let id, filename, originalFilename, hash: String
let bytes, originalBytes: Int
let host: String
let split, progress: Int
let status, added: String
let files: [TorrentInfoFile]
let links: [String]
let ended: String
enum CodingKeys: String, CodingKey {
case id, filename
case originalFilename = "original_filename"
case hash, bytes
case originalBytes = "original_bytes"
case host, split, progress, status, added, files, links, ended
}
enum CodingKeys: String, CodingKey {
case id, filename
case originalFilename = "original_filename"
case hash, bytes
case originalBytes = "original_bytes"
case host, split, progress, status, added, files, links, ended
}
}
struct TorrentInfoFile: Codable {
let id: Int
let path: String
let bytes, selected: Int
let id: Int
let path: String
let bytes, selected: Int
}
// MARK: - unrestrictLink endpoint
struct UnrestrictLinkResponse: Codable {
let id, filename, mimeType: String
let filesize: Int
let link: String
let host: String
let hostIcon: String
let chunks, crc: Int
let download: String
let streamable: Int
let id, filename, mimeType: String
let filesize: Int
let link: String
let host: String
let hostIcon: String
let chunks, crc: Int
let download: String
let streamable: Int
enum CodingKeys: String, CodingKey {
case id, filename, mimeType, filesize, link, host
case hostIcon = "host_icon"
case chunks, crc, download, streamable
}
enum CodingKeys: String, CodingKey {
case id, filename, mimeType, filesize, link, host
case hostIcon = "host_icon"
case chunks, crc, download, streamable
}
}

View file

@ -0,0 +1,18 @@
//
// SearchModels.swift
// Ferrite
//
// Created by Brian Dashore on 9/2/22.
//
import Foundation
public struct SearchResult: Hashable, Codable {
let title: String?
let source: String
let size: String?
let magnetLink: String?
let magnetHash: String?
let seeders: String?
let leechers: String?
}

View file

@ -10,8 +10,11 @@ import SwiftUI
@MainActor
public class DebridManager: ObservableObject {
// UI Variables
// Linked classes
var toastModel: ToastViewModel?
let realDebrid: RealDebrid = .init()
// UI Variables
@Published var showWebView: Bool = false
@Published var showLoadingProgress: Bool = false
@ -19,8 +22,6 @@ public class DebridManager: ObservableObject {
@Published var currentDebridTask: Task<Void, Never>?
// RealDebrid auth variables
let realDebrid: RealDebrid = .init()
@Published var realDebridEnabled: Bool = false {
didSet {
UserDefaults.standard.set(realDebridEnabled, forKey: "RealDebrid.Enabled")
@ -31,7 +32,7 @@ public class DebridManager: ObservableObject {
@Published var realDebridAuthUrl: String = ""
// RealDebrid fetch variables
@Published var realDebridHashes: [RealDebridIA] = []
@Published var realDebridIAValues: [RealDebridIA] = []
@Published var realDebridDownloadUrl: String = ""
@Published var selectedRealDebridItem: RealDebridIA?
@Published var selectedRealDebridFile: RealDebridIAFile?
@ -40,19 +41,30 @@ public class DebridManager: ObservableObject {
realDebridEnabled = UserDefaults.standard.bool(forKey: "RealDebrid.Enabled")
}
public func populateDebridHashes(_ searchResults: [SearchResult]) async {
var hashes: [String] = []
for result in searchResults {
if let hash = result.magnetHash {
hashes.append(hash)
}
}
public func populateDebridHashes(_ resultHashes: [String]) async {
do {
let debridHashes = try await realDebrid.instantAvailability(magnetHashes: hashes)
let now = Date()
realDebridHashes = debridHashes
// If a hash isn't found in the IA, update it
// If the hash is expired, remove it and update it
let sendHashes = resultHashes.filter { hash in
if let IAIndex = realDebridIAValues.firstIndex(where: { $0.hash == hash }) {
if now.timeIntervalSince1970 > realDebridIAValues[IAIndex].expiryTimeStamp {
realDebridIAValues.remove(at: IAIndex)
return true
} else {
return false
}
} else {
return true
}
}
if !sendHashes.isEmpty {
let fetchedIAValues = try await realDebrid.instantAvailability(magnetHashes: sendHashes)
realDebridIAValues += fetchedIAValues
}
} catch {
let error = error as NSError
@ -69,7 +81,7 @@ public class DebridManager: ObservableObject {
return .none
}
guard let debridMatch = realDebridHashes.first(where: { result.magnetHash == $0.hash }) else {
guard let debridMatch = realDebridIAValues.first(where: { result.magnetHash == $0.hash }) else {
return .none
}
@ -86,7 +98,7 @@ public class DebridManager: ObservableObject {
return false
}
if let realDebridItem = realDebridHashes.first(where: { magnetHash == $0.hash }) {
if let realDebridItem = realDebridIAValues.first(where: { magnetHash == $0.hash }) {
selectedRealDebridItem = realDebridItem
return true
} else {

View file

@ -11,6 +11,7 @@ enum ViewTab {
case search
case sources
case settings
case library
}
@MainActor
@ -31,6 +32,8 @@ class NavigationViewModel: ObservableObject {
@Published var isEditingSearch: Bool = false
@Published var isSearching: Bool = false
@Published var selectedSearchResult: SearchResult?
@Published var hideNavigationBar = false
@Published var currentChoiceSheet: ChoiceSheetType?
@ -86,11 +89,18 @@ class NavigationViewModel: ObservableObject {
}
}
public func runMagnetAction(action: DefaultMagnetActionType?, searchResult: SearchResult) {
public func runMagnetAction(_ action: DefaultMagnetActionType? = nil) {
guard let searchResult = selectedSearchResult else {
toastModel?.updateToastDescription("Magnet action error: A search result was not selected.")
print("Magnet action error: A search result was not selected.")
return
}
let selectedAction = action ?? defaultMagnetAction
guard let magnetLink = searchResult.magnetLink else {
toastModel?.toastDescription = "Could not run your action because the magnet link is invalid."
toastModel?.updateToastDescription("Could not run your action because the magnet link is invalid.")
print("Magnet action error: The magnet link is invalid.")
return

View file

@ -11,16 +11,6 @@ import SwiftSoup
import SwiftUI
import SwiftyJSON
public struct SearchResult: Hashable, Codable {
let title: String?
let source: String
let size: String?
let magnetLink: String?
let magnetHash: String?
let seeders: String?
let leechers: String?
}
class ScrapingViewModel: ObservableObject {
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
@ -31,7 +21,6 @@ class ScrapingViewModel: ObservableObject {
@Published var runningSearchTask: Task<Void, Error>?
@Published var searchResults: [SearchResult] = []
@Published var searchText: String = ""
@Published var selectedSearchResult: SearchResult?
@Published var filteredSource: Source?
@Published var currentSourceName: String?

View file

@ -21,7 +21,7 @@ struct BatchChoiceView: View {
Button(file.name) {
debridManager.selectedRealDebridFile = file
if let searchResult = scrapingModel.selectedSearchResult {
if let searchResult = navModel.selectedSearchResult {
debridManager.currentDebridTask = Task {
await debridManager.fetchRdDownload(searchResult: searchResult, iaFile: file)

View file

@ -0,0 +1,39 @@
//
// ConditionalContextMenu.swift
// Ferrite
//
// Created by Brian Dashore on 9/3/22.
//
// Used as a workaround for iOS 15 not updating context views with conditional variables
// A stateful ID is required for the contextMenu to update itself.
//
import SwiftUI
struct ConditionalContextMenu<InternalContent: View, ID: Hashable>: ViewModifier {
let internalContent: () -> InternalContent
let id: ID
init(@ViewBuilder _ internalContent: @escaping () -> InternalContent, id: ID) {
self.internalContent = internalContent
self.id = id
}
func body(content: Content) -> some View {
if #available(iOS 16, *) {
content
.contextMenu {
internalContent()
}
} else {
content
.background {
Color.clear
.contextMenu {
internalContent()
}
.id(id)
}
}
}
}

View file

@ -110,7 +110,11 @@ struct ContentView: View {
await scrapingModel.scanSources(sources: sources)
if realDebridEnabled, !scrapingModel.searchResults.isEmpty {
await debridManager.populateDebridHashes(scrapingModel.searchResults)
debridManager.realDebridIAValues = []
await debridManager.populateDebridHashes(
scrapingModel.searchResults.compactMap(\.magnetHash)
)
}
navModel.showSearchProgress = false

View file

@ -0,0 +1,83 @@
//
// Library.swift
// Ferrite
//
// Created by Brian Dashore on 9/2/22.
//
import SwiftUI
struct LibraryView: View {
enum LibraryPickerSegment {
case bookmarks
case history
}
@EnvironmentObject var navModel: NavigationViewModel
@FetchRequest(
entity: Bookmark.entity(),
sortDescriptors: [
NSSortDescriptor(keyPath: \Bookmark.orderNum, ascending: true)
]
) var bookmarks: FetchedResults<Bookmark>
@State private var historyEmpty = true
@State private var selectedSegment: LibraryPickerSegment = .bookmarks
@State private var editMode: EditMode = .inactive
var body: some View {
NavView {
VStack(spacing: 0) {
Picker("Segments", selection: $selectedSegment) {
Text("Bookmarks").tag(LibraryPickerSegment.bookmarks)
Text("History").tag(LibraryPickerSegment.history)
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.top)
switch selectedSegment {
case .bookmarks:
BookmarksView(bookmarks: bookmarks)
case .history:
HistoryView()
}
Spacer()
}
.overlay {
switch selectedSegment {
case .bookmarks:
if bookmarks.isEmpty {
EmptyInstructionView(title: "No Bookmarks", message: "Add a bookmark from search results")
}
case .history:
if historyEmpty {
EmptyInstructionView(title: "No History", message: "Start watching to build history")
}
}
}
.navigationTitle("Library")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
}
.environment(\.editMode, $editMode)
}
.onChange(of: selectedSegment) { _ in
editMode = .inactive
}
.onDisappear {
editMode = .inactive
}
}
}
struct LibraryView_Previews: PreviewProvider {
static var previews: some View {
LibraryView()
}
}

View file

@ -0,0 +1,67 @@
//
// BookmarksView.swift
// Ferrite
//
// Created by Brian Dashore on 9/2/22.
//
import SwiftUI
struct BookmarksView: View {
@Environment(\.verticalSizeClass) var verticalSizeClass
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
let backgroundContext = PersistenceController.shared.backgroundContext
var bookmarks: FetchedResults<Bookmark>
@State private var viewTask: Task<Void, Never>?
var body: some View {
ZStack {
if !bookmarks.isEmpty {
List {
ForEach(bookmarks, id: \.self) { bookmark in
SearchResultButtonView(result: bookmark.toSearchResult(), existingBookmark: bookmark)
}
.onDelete { offsets in
for index in offsets {
if let bookmark = bookmarks[safe: index] {
PersistenceController.shared.delete(bookmark, context: backgroundContext)
NotificationCenter.default.post(name: .didDeleteBookmark, object: nil)
}
}
}
.onMove { (source, destination) in
var changedBookmarks = bookmarks.map { $0 }
changedBookmarks.move(fromOffsets: source, toOffset: destination)
for reverseIndex in stride(from: changedBookmarks.count - 1, through: 0, by: -1) {
changedBookmarks[reverseIndex].orderNum = Int16(reverseIndex)
}
PersistenceController.shared.save()
}
}
.listStyle(.insetGrouped)
.onAppear {
if realDebridEnabled {
viewTask = Task {
let hashes = bookmarks.compactMap { $0.magnetHash }
await debridManager.populateDebridHashes(hashes)
}
}
}
.onDisappear {
viewTask?.cancel()
}
}
}
}
}

View file

@ -0,0 +1,22 @@
//
// HistoryView.swift
// Ferrite
//
// Created by Brian Dashore on 9/2/22.
//
import SwiftUI
struct HistoryView: View {
var body: some View {
ZStack {
EmptyView()
}
}
}
struct HistoryView_Previews: PreviewProvider {
static var previews: some View {
HistoryView()
}
}

View file

@ -23,7 +23,7 @@ struct MagnetChoiceView: View {
var body: some View {
NavView {
Form {
if realDebridEnabled, debridManager.matchSearchResult(result: scrapingModel.selectedSearchResult) != .none {
if realDebridEnabled, debridManager.matchSearchResult(result: navModel.selectedSearchResult) != .none {
Section(header: "Real Debrid options") {
ListRowButtonView("Play on Outplayer", systemImage: "arrow.up.forward.app.fill") {
navModel.runDebridAction(action: .outplayer, urlString: debridManager.realDebridDownloadUrl)
@ -60,7 +60,7 @@ struct MagnetChoiceView: View {
Section(header: "Magnet options") {
ListRowButtonView("Copy magnet", systemImage: "doc.on.doc.fill") {
UIPasteboard.general.string = scrapingModel.selectedSearchResult?.magnetLink
UIPasteboard.general.string = navModel.selectedSearchResult?.magnetLink
showMagnetCopyAlert.toggle()
}
.alert(isPresented: $showMagnetCopyAlert) {
@ -72,7 +72,7 @@ struct MagnetChoiceView: View {
}
ListRowButtonView("Share magnet", systemImage: "square.and.arrow.up.fill") {
if let result = scrapingModel.selectedSearchResult,
if let result = navModel.selectedSearchResult,
let magnetLink = result.magnetLink,
let url = URL(string: magnetLink)
{
@ -82,9 +82,7 @@ struct MagnetChoiceView: View {
}
ListRowButtonView("Open in WebTor", systemImage: "arrow.up.forward.app.fill") {
if let result = scrapingModel.selectedSearchResult {
navModel.runMagnetAction(action: .webtor, searchResult: result)
}
navModel.runMagnetAction(.webtor)
}
}
}

View file

@ -28,6 +28,12 @@ struct MainView: View {
}
.tag(ViewTab.search)
LibraryView()
.tabItem {
Label("Library", systemImage: "book.closed")
}
.tag(ViewTab.library)
SourcesView()
.tabItem {
Label("Sources", systemImage: "doc.text")

View file

@ -0,0 +1,109 @@
//
// SearchResultButtonView.swift
// Ferrite
//
// Created by Brian Dashore on 9/2/22.
//
import SwiftUI
// BUG: iOS 15 cannot refresh the context menu. Debating using swipe actions or adopting a workaround.
struct SearchResultButtonView: View {
let backgroundContext = PersistenceController.shared.backgroundContext
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var debridManager: DebridManager
var result: SearchResult
@State private var runOnce = false
@State var existingBookmark: Bookmark? = nil
var body: some View {
VStack(alignment: .leading) {
Button {
if debridManager.currentDebridTask == nil {
navModel.selectedSearchResult = result
switch debridManager.matchSearchResult(result: result) {
case .full:
debridManager.currentDebridTask = Task {
await debridManager.fetchRdDownload(searchResult: result)
if !debridManager.realDebridDownloadUrl.isEmpty {
navModel.runDebridAction(action: nil, urlString: debridManager.realDebridDownloadUrl)
}
}
case .partial:
if debridManager.setSelectedRdResult(result: result) {
navModel.currentChoiceSheet = .batch
}
case .none:
navModel.runMagnetAction()
}
}
} label: {
Text(result.title ?? "No title")
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
}
.dynamicAccentColor(.primary)
.padding(.bottom, 5)
.conditionalContextMenu(id: existingBookmark) {
if let bookmark = existingBookmark {
Button {
PersistenceController.shared.delete(bookmark, context: backgroundContext)
// When the entity is deleted, let other instances know to remove that reference
NotificationCenter.default.post(name: .didDeleteBookmark, object: nil)
} label: {
Text("Remove bookmark")
Image(systemName: "bookmark.slash.fill")
}
} else {
Button {
let newBookmark = Bookmark(context: backgroundContext)
newBookmark.title = result.title
newBookmark.source = result.source
newBookmark.magnetHash = result.magnetHash
newBookmark.magnetLink = result.magnetLink
newBookmark.seeders = result.seeders
newBookmark.leechers = result.leechers
existingBookmark = newBookmark
PersistenceController.shared.save(backgroundContext)
} label: {
Text("Bookmark")
Image(systemName: "bookmark")
}
}
}
SearchResultRDView(result: result)
}
.onReceive(NotificationCenter.default.publisher(for: .didDeleteBookmark)) { _ in
existingBookmark = nil
}
.onAppear {
// Only run a exists request if a bookmark isn't passed to the view
if existingBookmark == nil && !runOnce {
let bookmarkRequest = Bookmark.fetchRequest()
bookmarkRequest.predicate = NSPredicate(
format: "title == %@ AND source == %@ AND magnetLink == %@ AND magnetHash = %@",
result.title ?? "",
result.source,
result.magnetLink ?? "",
result.magnetHash ?? ""
)
bookmarkRequest.fetchLimit = 1
if let fetchedBookmark = try? backgroundContext.fetch(bookmarkRequest).first {
existingBookmark = fetchedBookmark
}
runOnce = true
}
}
}
}

View file

@ -9,7 +9,6 @@ import SwiftUI
struct SearchResultsView: View {
@EnvironmentObject var scrapingModel: ScrapingViewModel
@EnvironmentObject var debridManager: DebridManager
@EnvironmentObject var navModel: NavigationViewModel
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
@ -18,38 +17,7 @@ struct SearchResultsView: View {
List {
ForEach(scrapingModel.searchResults, id: \.self) { result in
if result.source == scrapingModel.filteredSource?.name || scrapingModel.filteredSource == nil {
VStack(alignment: .leading) {
Button {
if debridManager.currentDebridTask == nil {
scrapingModel.selectedSearchResult = result
switch debridManager.matchSearchResult(result: result) {
case .full:
debridManager.currentDebridTask = Task {
await debridManager.fetchRdDownload(searchResult: result)
if !debridManager.realDebridDownloadUrl.isEmpty {
navModel.runDebridAction(action: nil, urlString: debridManager.realDebridDownloadUrl)
}
}
case .partial:
if debridManager.setSelectedRdResult(result: result) {
navModel.currentChoiceSheet = .batch
}
case .none:
navModel.runMagnetAction(action: nil, searchResult: result)
}
}
} label: {
Text(result.title ?? "No title")
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
}
.dynamicAccentColor(.primary)
.padding(.bottom, 5)
SearchResultRDView(result: result)
}
SearchResultButtonView(result: result)
}
}
}

View file

@ -147,7 +147,7 @@ struct SourceSettingsMethodView: View {
var body: some View {
Section(header: InlineHeader("Fetch method")) {
if selectedSource.api != nil, selectedSource.jsonParser != nil {
if selectedSource.jsonParser != nil {
Button {
selectedSource.preferredParser = SourcePreferredParser.siteApi.rawValue
} label: {