mirror of
https://github.com/cranci1/Sora.git
synced 2026-03-11 17:45:37 +00:00
New Analytics feature
This commit is contained in:
parent
37a596fc8e
commit
80e82e60ae
6 changed files with 280 additions and 0 deletions
|
|
@ -51,6 +51,8 @@
|
|||
13EA2BD72D32D97400C1EBD7 /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD42D32D97400C1EBD7 /* MusicProgressSlider.swift */; };
|
||||
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */; };
|
||||
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; };
|
||||
730C73562D726DAB001EC57C /* Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 730C73552D726DA5001EC57C /* Analytics.swift */; };
|
||||
7374EF382D74AEF400319CEC /* UIDevice+Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7374EF372D74AEEA00319CEC /* UIDevice+Model.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
|
|
@ -99,6 +101,8 @@
|
|||
13EA2BD42D32D97400C1EBD7 /* MusicProgressSlider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = "<group>"; };
|
||||
13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalPlayer.swift; sourceTree = "<group>"; };
|
||||
1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = "<group>"; };
|
||||
730C73552D726DA5001EC57C /* Analytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Analytics.swift; sourceTree = "<group>"; };
|
||||
7374EF372D74AEEA00319CEC /* UIDevice+Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Model.swift"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
|
|
@ -236,6 +240,7 @@
|
|||
133D7C852D2BE2640075467E /* Utils */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
730C73542D726D83001EC57C /* Analytics */,
|
||||
13C0E5E82D5F85DD00E7F619 /* ContinueWatching */,
|
||||
13103E8C2D58E037000F0673 /* SkeletonCells */,
|
||||
13DC0C442D302C6A00D0F966 /* MediaPlayer */,
|
||||
|
|
@ -458,6 +463,7 @@
|
|||
133D7C902D2BE2640075467E /* SettingsView.swift in Sources */,
|
||||
13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */,
|
||||
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */,
|
||||
730C73562D726DAB001EC57C /* Analytics.swift in Sources */,
|
||||
13EA2BD72D32D97400C1EBD7 /* MusicProgressSlider.swift in Sources */,
|
||||
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */,
|
||||
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */,
|
||||
|
|
@ -475,6 +481,7 @@
|
|||
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */,
|
||||
133D7C8D2D2BE2640075467E /* HomeView.swift in Sources */,
|
||||
13D842552D45267500EBBFA6 /* DropManager.swift in Sources */,
|
||||
7374EF382D74AEF400319CEC /* UIDevice+Model.swift in Sources */,
|
||||
13103E8B2D58E028000F0673 /* View.swift in Sources */,
|
||||
138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */,
|
||||
133D7C8C2D2BE2640075467E /* SearchView.swift in Sources */,
|
||||
|
|
|
|||
125
Sora/Utils/Analytics/Analytics.swift
Normal file
125
Sora/Utils/Analytics/Analytics.swift
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
//
|
||||
// Analytics.swift
|
||||
// Sora
|
||||
//
|
||||
// Created by Hamzo on 28.02.25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
// MARK: - Analytics Response Model
|
||||
struct AnalyticsResponse: Codable {
|
||||
let status: String
|
||||
let message: String
|
||||
let event: String?
|
||||
let timestamp: String?
|
||||
}
|
||||
|
||||
// MARK: - Analytics Manager
|
||||
class AnalyticsManager {
|
||||
|
||||
static let shared = AnalyticsManager()
|
||||
private let analyticsURL = URL(string: "http://151.106.3.14:47474/analytics")!
|
||||
private let moduleManager = ModuleManager()
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Send Analytics Data
|
||||
func sendEvent(event: String, additionalData: [String: Any] = [:]) {
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
// Ensure the key is set with a default value if missing
|
||||
if defaults.object(forKey: "analyticsEnabled") == nil {
|
||||
print("Setting default value for analyticsEnabled")
|
||||
defaults.setValue(true, forKey: "analyticsEnabled")
|
||||
}
|
||||
|
||||
|
||||
let analyticsEnabled = UserDefaults.standard.bool(forKey: "analyticsEnabled")
|
||||
|
||||
guard analyticsEnabled else {
|
||||
Logger.shared.log("Analytics is disabled, skipping event: \(event)", type: "Debug")
|
||||
return
|
||||
}
|
||||
|
||||
guard let selectedModule = getSelectedModule() else {
|
||||
Logger.shared.log("No selected module found", type: "Debug")
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare analytics data
|
||||
var safeAdditionalData = additionalData
|
||||
|
||||
// Check and convert NSError if present
|
||||
if let errorValue = additionalData["error"] as? NSError {
|
||||
safeAdditionalData["error"] = errorValue.localizedDescription
|
||||
}
|
||||
|
||||
let analyticsData: [String: Any] = [
|
||||
"event": event,
|
||||
"device": getDeviceModel(),
|
||||
"app_version": getAppVersion(),
|
||||
"module_name": selectedModule.metadata.sourceName,
|
||||
"module_version": selectedModule.metadata.version,
|
||||
"data": safeAdditionalData
|
||||
]
|
||||
|
||||
sendRequest(with: analyticsData)
|
||||
}
|
||||
|
||||
// MARK: - Private Request Method
|
||||
private func sendRequest(with data: [String: Any]) {
|
||||
var request = URLRequest(url: analyticsURL)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
do {
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: data, options: [])
|
||||
} catch {
|
||||
Logger.shared.log("Failed to encode JSON: \(error.localizedDescription)", type: "Debug")
|
||||
return
|
||||
}
|
||||
|
||||
URLSession.shared.dataTask(with: request) { (data, response, error) in
|
||||
if let error = error {
|
||||
Logger.shared.log("Request failed: \(error.localizedDescription)", type: "Debug")
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data else {
|
||||
Logger.shared.log("No data received from server", type: "Debug")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let decodedResponse = try JSONDecoder().decode(AnalyticsResponse.self, from: data)
|
||||
if decodedResponse.status == "success" {
|
||||
Logger.shared.log("Analytics saved: \(decodedResponse.event ?? "unknown event") at \(decodedResponse.timestamp ?? "unknown time")", type: "Debug")
|
||||
} else {
|
||||
Logger.shared.log("Server error: \(decodedResponse.message)", type: "Debug")
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Failed to decode response: \(error.localizedDescription)", type: "Debug")
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
// MARK: - Get App Version
|
||||
private func getAppVersion() -> String {
|
||||
return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown_version"
|
||||
}
|
||||
|
||||
// MARK: - Get Device Model
|
||||
private func getDeviceModel() -> String {
|
||||
return UIDevice.modelName
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Get Selected Module
|
||||
private func getSelectedModule() -> ScrapingModule? {
|
||||
guard let selectedModuleId = UserDefaults.standard.string(forKey: "selectedModuleId") else { return nil }
|
||||
return moduleManager.modules.first { $0.id.uuidString == selectedModuleId }
|
||||
}
|
||||
}
|
||||
132
Sora/Utils/Analytics/UIDevice+Model.swift
Normal file
132
Sora/Utils/Analytics/UIDevice+Model.swift
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
//
|
||||
// UIDevice+Model.swift
|
||||
// Sora
|
||||
//
|
||||
// Created by Hamzo on 02.03.25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public extension UIDevice {
|
||||
|
||||
static let modelName: String = {
|
||||
var systemInfo = utsname()
|
||||
uname(&systemInfo)
|
||||
let machineMirror = Mirror(reflecting: systemInfo.machine)
|
||||
let identifier = machineMirror.children.reduce("") { identifier, element in
|
||||
guard let value = element.value as? Int8, value != 0 else { return identifier }
|
||||
return identifier + String(UnicodeScalar(UInt8(value)))
|
||||
}
|
||||
|
||||
func mapToDevice(identifier: String) -> String { // swiftlint:disable:this cyclomatic_complexity
|
||||
#if os(iOS)
|
||||
switch identifier {
|
||||
case "iPod5,1": return "iPod touch (5th generation)"
|
||||
case "iPod7,1": return "iPod touch (6th generation)"
|
||||
case "iPod9,1": return "iPod touch (7th generation)"
|
||||
case "iPhone3,1", "iPhone3,2", "iPhone3,3": return "iPhone 4"
|
||||
case "iPhone4,1": return "iPhone 4s"
|
||||
case "iPhone5,1", "iPhone5,2": return "iPhone 5"
|
||||
case "iPhone5,3", "iPhone5,4": return "iPhone 5c"
|
||||
case "iPhone6,1", "iPhone6,2": return "iPhone 5s"
|
||||
case "iPhone7,2": return "iPhone 6"
|
||||
case "iPhone7,1": return "iPhone 6 Plus"
|
||||
case "iPhone8,1": return "iPhone 6s"
|
||||
case "iPhone8,2": return "iPhone 6s Plus"
|
||||
case "iPhone9,1", "iPhone9,3": return "iPhone 7"
|
||||
case "iPhone9,2", "iPhone9,4": return "iPhone 7 Plus"
|
||||
case "iPhone10,1", "iPhone10,4": return "iPhone 8"
|
||||
case "iPhone10,2", "iPhone10,5": return "iPhone 8 Plus"
|
||||
case "iPhone10,3", "iPhone10,6": return "iPhone X"
|
||||
case "iPhone11,2": return "iPhone XS"
|
||||
case "iPhone11,4", "iPhone11,6": return "iPhone XS Max"
|
||||
case "iPhone11,8": return "iPhone XR"
|
||||
case "iPhone12,1": return "iPhone 11"
|
||||
case "iPhone12,3": return "iPhone 11 Pro"
|
||||
case "iPhone12,5": return "iPhone 11 Pro Max"
|
||||
case "iPhone13,1": return "iPhone 12 mini"
|
||||
case "iPhone13,2": return "iPhone 12"
|
||||
case "iPhone13,3": return "iPhone 12 Pro"
|
||||
case "iPhone13,4": return "iPhone 12 Pro Max"
|
||||
case "iPhone14,4": return "iPhone 13 mini"
|
||||
case "iPhone14,5": return "iPhone 13"
|
||||
case "iPhone14,2": return "iPhone 13 Pro"
|
||||
case "iPhone14,3": return "iPhone 13 Pro Max"
|
||||
case "iPhone14,7": return "iPhone 14"
|
||||
case "iPhone14,8": return "iPhone 14 Plus"
|
||||
case "iPhone15,2": return "iPhone 14 Pro"
|
||||
case "iPhone15,3": return "iPhone 14 Pro Max"
|
||||
case "iPhone15,4": return "iPhone 15"
|
||||
case "iPhone15,5": return "iPhone 15 Plus"
|
||||
case "iPhone16,1": return "iPhone 15 Pro"
|
||||
case "iPhone16,2": return "iPhone 15 Pro Max"
|
||||
case "iPhone17,3": return "iPhone 16"
|
||||
case "iPhone17,4": return "iPhone 16 Plus"
|
||||
case "iPhone17,1": return "iPhone 16 Pro"
|
||||
case "iPhone17,2": return "iPhone 16 Pro Max"
|
||||
case "iPhone8,4": return "iPhone SE"
|
||||
case "iPhone12,8": return "iPhone SE (2nd generation)"
|
||||
case "iPhone14,6": return "iPhone SE (3rd generation)"
|
||||
case "iPad2,1", "iPad2,2", "iPad2,3", "iPad2,4": return "iPad 2"
|
||||
case "iPad3,1", "iPad3,2", "iPad3,3": return "iPad (3rd generation)"
|
||||
case "iPad3,4", "iPad3,5", "iPad3,6": return "iPad (4th generation)"
|
||||
case "iPad6,11", "iPad6,12": return "iPad (5th generation)"
|
||||
case "iPad7,5", "iPad7,6": return "iPad (6th generation)"
|
||||
case "iPad7,11", "iPad7,12": return "iPad (7th generation)"
|
||||
case "iPad11,6", "iPad11,7": return "iPad (8th generation)"
|
||||
case "iPad12,1", "iPad12,2": return "iPad (9th generation)"
|
||||
case "iPad13,18", "iPad13,19": return "iPad (10th generation)"
|
||||
case "iPad4,1", "iPad4,2", "iPad4,3": return "iPad Air"
|
||||
case "iPad5,3", "iPad5,4": return "iPad Air 2"
|
||||
case "iPad11,3", "iPad11,4": return "iPad Air (3rd generation)"
|
||||
case "iPad13,1", "iPad13,2": return "iPad Air (4th generation)"
|
||||
case "iPad13,16", "iPad13,17": return "iPad Air (5th generation)"
|
||||
case "iPad14,8", "iPad14,9": return "iPad Air (11-inch) (M2)"
|
||||
case "iPad14,10", "iPad14,11": return "iPad Air (13-inch) (M2)"
|
||||
case "iPad2,5", "iPad2,6", "iPad2,7": return "iPad mini"
|
||||
case "iPad4,4", "iPad4,5", "iPad4,6": return "iPad mini 2"
|
||||
case "iPad4,7", "iPad4,8", "iPad4,9": return "iPad mini 3"
|
||||
case "iPad5,1", "iPad5,2": return "iPad mini 4"
|
||||
case "iPad11,1", "iPad11,2": return "iPad mini (5th generation)"
|
||||
case "iPad14,1", "iPad14,2": return "iPad mini (6th generation)"
|
||||
case "iPad16,1", "iPad16,2": return "iPad mini (A17 Pro)"
|
||||
case "iPad6,3", "iPad6,4": return "iPad Pro (9.7-inch)"
|
||||
case "iPad7,3", "iPad7,4": return "iPad Pro (10.5-inch)"
|
||||
case "iPad8,1", "iPad8,2", "iPad8,3", "iPad8,4": return "iPad Pro (11-inch) (1st generation)"
|
||||
case "iPad8,9", "iPad8,10": return "iPad Pro (11-inch) (2nd generation)"
|
||||
case "iPad13,4", "iPad13,5", "iPad13,6", "iPad13,7": return "iPad Pro (11-inch) (3rd generation)"
|
||||
case "iPad14,3", "iPad14,4": return "iPad Pro (11-inch) (4th generation)"
|
||||
case "iPad16,3", "iPad16,4": return "iPad Pro (11-inch) (M4)"
|
||||
case "iPad6,7", "iPad6,8": return "iPad Pro (12.9-inch) (1st generation)"
|
||||
case "iPad7,1", "iPad7,2": return "iPad Pro (12.9-inch) (2nd generation)"
|
||||
case "iPad8,5", "iPad8,6", "iPad8,7", "iPad8,8": return "iPad Pro (12.9-inch) (3rd generation)"
|
||||
case "iPad8,11", "iPad8,12": return "iPad Pro (12.9-inch) (4th generation)"
|
||||
case "iPad13,8", "iPad13,9", "iPad13,10", "iPad13,11":return "iPad Pro (12.9-inch) (5th generation)"
|
||||
case "iPad14,5", "iPad14,6": return "iPad Pro (12.9-inch) (6th generation)"
|
||||
case "iPad16,5", "iPad16,6": return "iPad Pro (13-inch) (M4)"
|
||||
case "AppleTV5,3": return "Apple TV"
|
||||
case "AppleTV6,2": return "Apple TV 4K"
|
||||
case "AudioAccessory1,1": return "HomePod"
|
||||
case "AudioAccessory5,1": return "HomePod mini"
|
||||
case "i386", "x86_64", "arm64": return "Simulator \(mapToDevice(identifier: ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "iOS"))"
|
||||
default: return identifier
|
||||
}
|
||||
#elseif os(tvOS)
|
||||
switch identifier {
|
||||
case "AppleTV5,3": return "Apple TV 4"
|
||||
case "AppleTV6,2", "AppleTV11,1", "AppleTV14,1": return "Apple TV 4K"
|
||||
case "i386", "x86_64": return "Simulator \(mapToDevice(identifier: ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "tvOS"))"
|
||||
default: return identifier
|
||||
}
|
||||
#elseif os(visionOS)
|
||||
switch identifier {
|
||||
case "RealityDevice14,1": return "Apple Vision Pro"
|
||||
default: return identifier
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
return mapToDevice(identifier: identifier)
|
||||
}()
|
||||
|
||||
}
|
||||
|
|
@ -31,6 +31,13 @@ class Logger {
|
|||
let entry = LogEntry(message: message, type: type, timestamp: Date())
|
||||
logs.append(entry)
|
||||
saveLogToFile(entry)
|
||||
|
||||
// Print to Xcode console
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "dd-MM HH:mm:ss"
|
||||
let formattedMessage = "[\(dateFormatter.string(from: entry.timestamp))] [\(entry.type)] \(entry.message)"
|
||||
|
||||
//print(formattedMessage) // TODO: Remove this line in production, DEBUG only
|
||||
}
|
||||
|
||||
func getLogs() -> String {
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ struct MediaInfoView: View {
|
|||
selectedEpisodeNumber = ep.number
|
||||
selectedEpisodeImage = imageUrl
|
||||
fetchStream(href: ep.href)
|
||||
AnalyticsManager.shared.sendEvent(event: "watch", additionalData: ["title": title, "episode": ep.number])
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -268,9 +269,11 @@ struct MediaInfoView: View {
|
|||
itemID = id
|
||||
case .failure(let error):
|
||||
Logger.shared.log("Failed to fetch Item ID: \(error)")
|
||||
AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch Item ID"])
|
||||
}
|
||||
}
|
||||
hasFetched = true
|
||||
AnalyticsManager.shared.sendEvent(event: "search", additionalData: ["title": title])
|
||||
}
|
||||
selectedRange = 0..<episodeChunkSize
|
||||
}
|
||||
|
|
@ -476,6 +479,7 @@ struct MediaInfoView: View {
|
|||
func handleStreamFailure(error: Error? = nil) {
|
||||
if let error = error {
|
||||
Logger.shared.log("Error loading module: \(error)", type: "Error")
|
||||
AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch stream"])
|
||||
}
|
||||
DropManager.shared.showDrop(title: "Stream not Found", subtitle: "", duration: 1.0, icon: UIImage(systemName: "xmark"))
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ struct SettingsViewGeneral: View {
|
|||
@AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100
|
||||
@AppStorage("refreshModulesOnLaunch") private var refreshModulesOnLaunch: Bool = false
|
||||
@AppStorage("fetchEpisodeMetadata") private var fetchEpisodeMetadata: Bool = true
|
||||
@AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = true
|
||||
@EnvironmentObject var settings: Settings
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -57,6 +58,10 @@ struct SettingsViewGeneral: View {
|
|||
Toggle("Refresh Modules on Launch", isOn: $refreshModulesOnLaunch)
|
||||
.tint(.accentColor)
|
||||
}
|
||||
Section(header: Text("Analytics"), footer: Text("Allow Sora to collect anonymous data to improve the app. No personal information is collected. This can be disabled at any time.\n\n Information collected: \n- App version\n- Device model\n- Module Name/Version\n- Error Messages\n- Title of Watched Content")) {
|
||||
Toggle("Enable Analytics", isOn: $analyticsEnabled)
|
||||
.tint(.accentColor)
|
||||
}
|
||||
}
|
||||
.navigationTitle("General")
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue