mirror of
https://github.com/yattee/yattee.git
synced 2026-04-10 17:46:58 +00:00
Yattee v2 rewrite
This commit is contained in:
223
Yattee/Services/MediaSources/LocalFileClient.swift
Normal file
223
Yattee/Services/MediaSources/LocalFileClient.swift
Normal file
@@ -0,0 +1,223 @@
|
||||
//
|
||||
// LocalFileClient.swift
|
||||
// Yattee
|
||||
//
|
||||
// Client for browsing local folders from Files app (iOS) or filesystem (macOS).
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// Actor-based client for local file system operations.
|
||||
actor LocalFileClient {
|
||||
private let fileManager = FileManager.default
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Lists files in a local folder.
|
||||
/// - Parameters:
|
||||
/// - url: The folder URL to list.
|
||||
/// - source: The media source configuration.
|
||||
/// - Returns: Array of files and folders in the directory.
|
||||
func listFiles(
|
||||
in url: URL,
|
||||
source: MediaSource
|
||||
) async throws -> [MediaFile] {
|
||||
guard source.type == .localFolder else {
|
||||
throw MediaSourceError.unknown("Invalid source type for LocalFileClient")
|
||||
}
|
||||
|
||||
// Start accessing security-scoped resource
|
||||
let didStartAccessing = url.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if didStartAccessing {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
var isDirectory: ObjCBool = false
|
||||
guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) else {
|
||||
throw MediaSourceError.pathNotFound(url.path)
|
||||
}
|
||||
|
||||
guard isDirectory.boolValue else {
|
||||
throw MediaSourceError.notADirectory
|
||||
}
|
||||
|
||||
let contents: [URL]
|
||||
do {
|
||||
contents = try fileManager.contentsOfDirectory(
|
||||
at: url,
|
||||
includingPropertiesForKeys: [
|
||||
.isDirectoryKey,
|
||||
.fileSizeKey,
|
||||
.contentModificationDateKey,
|
||||
.creationDateKey,
|
||||
.contentTypeKey
|
||||
],
|
||||
options: [.skipsHiddenFiles]
|
||||
)
|
||||
} catch {
|
||||
throw MediaSourceError.accessDenied
|
||||
}
|
||||
|
||||
var files: [MediaFile] = []
|
||||
|
||||
for fileURL in contents {
|
||||
if let file = try? createMediaFile(from: fileURL, source: source) {
|
||||
files.append(file)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: directories first, then alphabetically
|
||||
files.sort { lhs, rhs in
|
||||
if lhs.isDirectory != rhs.isDirectory {
|
||||
return lhs.isDirectory
|
||||
}
|
||||
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
/// Lists files relative to the source root URL.
|
||||
/// - Parameters:
|
||||
/// - path: Path relative to source URL (or empty for root).
|
||||
/// - source: The media source configuration.
|
||||
/// - Returns: Array of files and folders.
|
||||
func listFiles(
|
||||
at path: String,
|
||||
source: MediaSource
|
||||
) async throws -> [MediaFile] {
|
||||
// Resolve bookmark to get valid URL (required after app restart on iOS)
|
||||
let baseURL: URL
|
||||
if let bookmarkData = source.bookmarkData {
|
||||
baseURL = try resolveBookmark(bookmarkData)
|
||||
} else {
|
||||
baseURL = source.url
|
||||
}
|
||||
|
||||
let url: URL
|
||||
if path.isEmpty || path == "/" {
|
||||
url = baseURL
|
||||
} else {
|
||||
let cleanPath = path.hasPrefix("/") ? String(path.dropFirst()) : path
|
||||
url = baseURL.appendingPathComponent(cleanPath)
|
||||
}
|
||||
return try await listFiles(in: url, source: source)
|
||||
}
|
||||
|
||||
// MARK: - Security-Scoped Bookmarks
|
||||
|
||||
/// Creates a security-scoped bookmark for persistent folder access.
|
||||
/// - Parameter url: The folder URL to bookmark.
|
||||
/// - Returns: Bookmark data that can be stored for later access.
|
||||
func createBookmark(for url: URL) throws -> Data {
|
||||
// Start accessing security-scoped resource if needed
|
||||
let didStartAccessing = url.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if didStartAccessing {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
let options: URL.BookmarkCreationOptions = [
|
||||
.withSecurityScope,
|
||||
.securityScopeAllowOnlyReadAccess
|
||||
]
|
||||
#else
|
||||
let options: URL.BookmarkCreationOptions = []
|
||||
#endif
|
||||
|
||||
do {
|
||||
return try url.bookmarkData(
|
||||
options: options,
|
||||
includingResourceValuesForKeys: nil,
|
||||
relativeTo: nil
|
||||
)
|
||||
} catch {
|
||||
throw MediaSourceError.unknown("Failed to create bookmark: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a security-scoped bookmark to a URL.
|
||||
/// - Parameter bookmarkData: The stored bookmark data.
|
||||
/// - Returns: The resolved URL with access granted.
|
||||
func resolveBookmark(_ bookmarkData: Data) throws -> URL {
|
||||
var isStale = false
|
||||
|
||||
#if os(macOS)
|
||||
let options: URL.BookmarkResolutionOptions = [.withSecurityScope]
|
||||
#else
|
||||
let options: URL.BookmarkResolutionOptions = []
|
||||
#endif
|
||||
|
||||
let url: URL
|
||||
do {
|
||||
url = try URL(
|
||||
resolvingBookmarkData: bookmarkData,
|
||||
options: options,
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &isStale
|
||||
)
|
||||
} catch {
|
||||
throw MediaSourceError.bookmarkResolutionFailed
|
||||
}
|
||||
|
||||
if isStale {
|
||||
// Bookmark is stale, but we might still have access
|
||||
// The caller should re-create the bookmark if possible
|
||||
throw MediaSourceError.bookmarkResolutionFailed
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/// Resolves bookmark and starts accessing the security-scoped resource.
|
||||
/// - Parameter bookmarkData: The stored bookmark data.
|
||||
/// - Returns: Tuple of (URL, didStartAccessing) - caller must call stopAccessingSecurityScopedResource when done.
|
||||
func resolveAndAccessBookmark(_ bookmarkData: Data) throws -> (URL, Bool) {
|
||||
let url = try resolveBookmark(bookmarkData)
|
||||
let didStart = url.startAccessingSecurityScopedResource()
|
||||
return (url, didStart)
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func createMediaFile(
|
||||
from url: URL,
|
||||
source: MediaSource
|
||||
) throws -> MediaFile {
|
||||
let resourceValues = try url.resourceValues(forKeys: [
|
||||
.isDirectoryKey,
|
||||
.fileSizeKey,
|
||||
.contentModificationDateKey,
|
||||
.creationDateKey,
|
||||
.contentTypeKey
|
||||
])
|
||||
|
||||
let isDirectory = resourceValues.isDirectory ?? false
|
||||
let size = resourceValues.fileSize.map { Int64($0) }
|
||||
let modifiedDate = resourceValues.contentModificationDate
|
||||
let createdDate = resourceValues.creationDate
|
||||
let contentType = resourceValues.contentType
|
||||
|
||||
// Calculate relative path from source root
|
||||
let relativePath = url.path.replacingOccurrences(
|
||||
of: source.url.path,
|
||||
with: ""
|
||||
)
|
||||
|
||||
return MediaFile(
|
||||
source: source,
|
||||
path: relativePath,
|
||||
name: url.lastPathComponent,
|
||||
isDirectory: isDirectory,
|
||||
size: size,
|
||||
modifiedDate: modifiedDate,
|
||||
createdDate: createdDate,
|
||||
mimeType: contentType?.preferredMIMEType
|
||||
)
|
||||
}
|
||||
}
|
||||
104
Yattee/Services/MediaSources/MediaSourceError.swift
Normal file
104
Yattee/Services/MediaSources/MediaSourceError.swift
Normal file
@@ -0,0 +1,104 @@
|
||||
//
|
||||
// MediaSourceError.swift
|
||||
// Yattee
|
||||
//
|
||||
// Errors for media source operations.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Errors that can occur during media source operations.
|
||||
enum MediaSourceError: Error, LocalizedError, Equatable, Sendable {
|
||||
/// Failed to connect to the media source.
|
||||
case connectionFailed(String)
|
||||
|
||||
/// Authentication failed or is required.
|
||||
case authenticationFailed
|
||||
|
||||
/// The requested path was not found.
|
||||
case pathNotFound(String)
|
||||
|
||||
/// Failed to parse the response (WebDAV XML, etc.).
|
||||
case parsingFailed(String)
|
||||
|
||||
/// The path is not a directory.
|
||||
case notADirectory
|
||||
|
||||
/// The source returned an invalid response.
|
||||
case invalidResponse
|
||||
|
||||
/// The bookmark could not be resolved (local folders).
|
||||
case bookmarkResolutionFailed
|
||||
|
||||
/// Access to the file/folder was denied.
|
||||
case accessDenied
|
||||
|
||||
/// The operation timed out.
|
||||
case timeout
|
||||
|
||||
/// No network connection available.
|
||||
case noConnection
|
||||
|
||||
/// An unknown error occurred.
|
||||
case unknown(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .connectionFailed(let message):
|
||||
return "Connection failed: \(message)"
|
||||
case .authenticationFailed:
|
||||
return "Authentication failed"
|
||||
case .pathNotFound(let path):
|
||||
return "Path not found: \(path)"
|
||||
case .parsingFailed(let message):
|
||||
return "Failed to parse response: \(message)"
|
||||
case .notADirectory:
|
||||
return "The path is not a directory"
|
||||
case .invalidResponse:
|
||||
return "Invalid response from server"
|
||||
case .bookmarkResolutionFailed:
|
||||
return "Could not access the folder. Please re-add it."
|
||||
case .accessDenied:
|
||||
return "Access denied"
|
||||
case .timeout:
|
||||
return "Request timed out"
|
||||
case .noConnection:
|
||||
return "No network connection"
|
||||
case .unknown(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this error is likely recoverable by retrying.
|
||||
var isRetryable: Bool {
|
||||
switch self {
|
||||
case .timeout, .noConnection, .connectionFailed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func == (lhs: MediaSourceError, rhs: MediaSourceError) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.authenticationFailed, .authenticationFailed),
|
||||
(.notADirectory, .notADirectory),
|
||||
(.invalidResponse, .invalidResponse),
|
||||
(.bookmarkResolutionFailed, .bookmarkResolutionFailed),
|
||||
(.accessDenied, .accessDenied),
|
||||
(.timeout, .timeout),
|
||||
(.noConnection, .noConnection):
|
||||
return true
|
||||
case (.connectionFailed(let lMsg), .connectionFailed(let rMsg)):
|
||||
return lMsg == rMsg
|
||||
case (.pathNotFound(let lPath), .pathNotFound(let rPath)):
|
||||
return lPath == rPath
|
||||
case (.parsingFailed(let lMsg), .parsingFailed(let rMsg)):
|
||||
return lMsg == rMsg
|
||||
case (.unknown(let lMsg), .unknown(let rMsg)):
|
||||
return lMsg == rMsg
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
263
Yattee/Services/MediaSources/NetworkShareDiscoveryService.swift
Normal file
263
Yattee/Services/MediaSources/NetworkShareDiscoveryService.swift
Normal file
@@ -0,0 +1,263 @@
|
||||
//
|
||||
// NetworkShareDiscoveryService.swift
|
||||
// Yattee
|
||||
//
|
||||
// Discovers WebDAV and SMB shares on the local network using Bonjour/mDNS.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// A network share discovered via Bonjour/mDNS.
|
||||
struct DiscoveredShare: Identifiable, Hashable, Sendable {
|
||||
let id = UUID()
|
||||
let name: String // Service name (e.g., "Synology")
|
||||
let host: String // Hostname (e.g., "synology.local")
|
||||
let port: Int? // Port if non-standard
|
||||
let path: String? // WebDAV path from TXT record
|
||||
let type: ShareType
|
||||
|
||||
enum ShareType: String, CaseIterable, Sendable {
|
||||
case webdav // _webdav._tcp (HTTP)
|
||||
case webdavs // _webdavs._tcp (HTTPS)
|
||||
case smb // _smb._tcp
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .webdav: String(localized: "discovery.shareType.webdav")
|
||||
case .webdavs: String(localized: "discovery.shareType.webdavs")
|
||||
case .smb: String(localized: "discovery.shareType.smb")
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .webdav: "globe"
|
||||
case .webdavs: "lock.shield"
|
||||
case .smb: "folder.badge.gearshape"
|
||||
}
|
||||
}
|
||||
|
||||
var serviceType: String {
|
||||
switch self {
|
||||
case .webdav: "_webdav._tcp"
|
||||
case .webdavs: "_webdavs._tcp"
|
||||
case .smb: "_smb._tcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs a URL for this share.
|
||||
var url: URL? {
|
||||
var components = URLComponents()
|
||||
|
||||
switch type {
|
||||
case .webdav:
|
||||
components.scheme = "http"
|
||||
case .webdavs:
|
||||
components.scheme = "https"
|
||||
case .smb:
|
||||
components.scheme = "smb"
|
||||
}
|
||||
|
||||
components.host = host
|
||||
|
||||
if let port, port != defaultPort {
|
||||
components.port = port
|
||||
}
|
||||
|
||||
if let path, !path.isEmpty {
|
||||
components.path = path.hasPrefix("/") ? path : "/\(path)"
|
||||
}
|
||||
|
||||
return components.url
|
||||
}
|
||||
|
||||
private var defaultPort: Int {
|
||||
switch type {
|
||||
case .webdav: 80
|
||||
case .webdavs: 443
|
||||
case .smb: 445
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Service for discovering WebDAV and SMB shares on the local network.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class NetworkShareDiscoveryService {
|
||||
|
||||
// MARK: - Public State
|
||||
|
||||
/// Discovered shares on the local network.
|
||||
private(set) var discoveredShares: [DiscoveredShare] = []
|
||||
|
||||
/// Whether the service is actively scanning.
|
||||
private(set) var isScanning: Bool = false
|
||||
|
||||
// MARK: - Private State
|
||||
|
||||
private var browsers: [NWBrowser] = []
|
||||
private var discoveryTask: Task<Void, Never>?
|
||||
private let queue = DispatchQueue(label: "stream.yattee.networksharediscovery")
|
||||
|
||||
/// Duration to scan before automatically stopping (in seconds).
|
||||
private let scanDuration: TimeInterval = 5.0
|
||||
|
||||
// MARK: - Discovery
|
||||
|
||||
/// Start discovering network shares. Automatically stops after 5 seconds.
|
||||
func startDiscovery() {
|
||||
guard !isScanning else {
|
||||
LoggingService.shared.logMediaSourcesDebug("Already scanning, ignoring duplicate start")
|
||||
return
|
||||
}
|
||||
|
||||
LoggingService.shared.logMediaSources("Starting network share discovery")
|
||||
|
||||
// Clear previous results
|
||||
discoveredShares = []
|
||||
isScanning = true
|
||||
|
||||
// Start browsers for each service type
|
||||
for shareType in DiscoveredShare.ShareType.allCases {
|
||||
startBrowser(for: shareType)
|
||||
}
|
||||
|
||||
// Auto-stop after scan duration
|
||||
discoveryTask = Task { @MainActor in
|
||||
try? await Task.sleep(for: .seconds(scanDuration))
|
||||
if isScanning {
|
||||
LoggingService.shared.logMediaSources("Scan timeout reached, stopping discovery")
|
||||
stopDiscovery()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop discovering network shares.
|
||||
func stopDiscovery() {
|
||||
guard isScanning else { return }
|
||||
|
||||
LoggingService.shared.logMediaSources("Stopping network share discovery, found \(self.discoveredShares.count) shares")
|
||||
|
||||
discoveryTask?.cancel()
|
||||
discoveryTask = nil
|
||||
|
||||
for browser in browsers {
|
||||
browser.cancel()
|
||||
}
|
||||
browsers.removeAll()
|
||||
|
||||
isScanning = false
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func startBrowser(for shareType: DiscoveredShare.ShareType) {
|
||||
let parameters = NWParameters()
|
||||
parameters.includePeerToPeer = true
|
||||
|
||||
let browser = NWBrowser(
|
||||
for: .bonjour(type: shareType.serviceType, domain: nil),
|
||||
using: parameters
|
||||
)
|
||||
|
||||
browser.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleBrowserStateChange(state, shareType: shareType)
|
||||
}
|
||||
}
|
||||
|
||||
browser.browseResultsChangedHandler = { [weak self] results, changes in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleBrowseResultsChanged(results: results, changes: changes, shareType: shareType)
|
||||
}
|
||||
}
|
||||
|
||||
browser.start(queue: queue)
|
||||
browsers.append(browser)
|
||||
|
||||
LoggingService.shared.logMediaSourcesDebug("Started browser for \(shareType.serviceType)")
|
||||
}
|
||||
|
||||
private func handleBrowserStateChange(_ state: NWBrowser.State, shareType: DiscoveredShare.ShareType) {
|
||||
switch state {
|
||||
case .ready:
|
||||
LoggingService.shared.logMediaSourcesDebug("Browser ready for \(shareType.serviceType)")
|
||||
case .failed(let error):
|
||||
LoggingService.shared.logMediaSourcesError("Browser failed for \(shareType.serviceType)", error: error)
|
||||
case .cancelled:
|
||||
LoggingService.shared.logMediaSourcesDebug("Browser cancelled for \(shareType.serviceType)")
|
||||
case .waiting(let error):
|
||||
LoggingService.shared.logMediaSourcesWarning("Browser waiting for \(shareType.serviceType)", details: error.localizedDescription)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleBrowseResultsChanged(
|
||||
results: Set<NWBrowser.Result>,
|
||||
changes: Set<NWBrowser.Result.Change>,
|
||||
shareType: DiscoveredShare.ShareType
|
||||
) {
|
||||
for change in changes {
|
||||
switch change {
|
||||
case .added(let result):
|
||||
if let share = parseShare(from: result, shareType: shareType) {
|
||||
// Avoid duplicates
|
||||
if !discoveredShares.contains(where: { $0.host == share.host && $0.type == share.type && $0.name == share.name }) {
|
||||
discoveredShares.append(share)
|
||||
LoggingService.shared.logMediaSources("Discovered \(shareType.rawValue) share: \(share.name) at \(share.host)")
|
||||
}
|
||||
}
|
||||
|
||||
case .removed(let result):
|
||||
if case let .service(name, _, _, _) = result.endpoint {
|
||||
discoveredShares.removeAll { $0.name == name && $0.type == shareType }
|
||||
LoggingService.shared.logMediaSourcesDebug("Removed \(shareType.rawValue) share: \(name)")
|
||||
}
|
||||
|
||||
case .changed, .identical:
|
||||
break
|
||||
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func parseShare(from result: NWBrowser.Result, shareType: DiscoveredShare.ShareType) -> DiscoveredShare? {
|
||||
guard case let .service(name, _, _, _) = result.endpoint else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract host from endpoint - use the service name with .local suffix
|
||||
let host = "\(name).local"
|
||||
|
||||
// Parse TXT record for additional info
|
||||
var path: String?
|
||||
var port: Int?
|
||||
|
||||
if case let .bonjour(txtRecord) = result.metadata {
|
||||
let dict = txtRecord.dictionary
|
||||
|
||||
// WebDAV servers often advertise the path in TXT record
|
||||
if let txtPath = dict["path"] {
|
||||
path = txtPath
|
||||
}
|
||||
|
||||
// Some servers advertise port in TXT record
|
||||
if let txtPort = dict["port"], let portNum = Int(txtPort) {
|
||||
port = portNum
|
||||
}
|
||||
}
|
||||
|
||||
return DiscoveredShare(
|
||||
name: name,
|
||||
host: host,
|
||||
port: port,
|
||||
path: path,
|
||||
type: shareType
|
||||
)
|
||||
}
|
||||
}
|
||||
504
Yattee/Services/MediaSources/SMBBridge/SMBBridge.c
Normal file
504
Yattee/Services/MediaSources/SMBBridge/SMBBridge.c
Normal file
@@ -0,0 +1,504 @@
|
||||
//
|
||||
// SMBBridge.c
|
||||
// Yattee
|
||||
//
|
||||
// C implementation of libsmbclient bridge for directory browsing using context-specific
|
||||
// function pointers. This provides complete isolation from other libsmbclient users
|
||||
// (e.g., FFmpeg in MPV) by avoiding smbc_set_context() which modifies global state.
|
||||
//
|
||||
|
||||
#include "SMBBridge.h"
|
||||
#include "libsmbclient_minimal.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <pthread.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
|
||||
// Context wrapper that bundles SMBCCTX with auth data
|
||||
struct SMBContextWrapper {
|
||||
SMBCCTX *ctx; // Isolated libsmbclient context
|
||||
char workgroup[128];
|
||||
char username[128];
|
||||
char password[128];
|
||||
SMBProtocolVersion version;
|
||||
pthread_mutex_t mutex; // Per-context mutex for thread safety
|
||||
};
|
||||
|
||||
// Authentication callback for libsmbclient context (called during SMB operations)
|
||||
static void auth_fn_with_context(
|
||||
SMBCCTX *ctx,
|
||||
const char *server, const char *share,
|
||||
char *workgroup, int wgmaxlen,
|
||||
char *username, int unmaxlen,
|
||||
char *password, int pwmaxlen
|
||||
) {
|
||||
fprintf(stderr, "[SMBBridge] Auth callback invoked for server: %s, share: %s\n",
|
||||
server ? server : "(null)", share ? share : "(null)");
|
||||
|
||||
// Get auth data from context's user data
|
||||
struct SMBContextWrapper *wrapper = (struct SMBContextWrapper *)smbc_getOptionUserData(ctx);
|
||||
if (wrapper) {
|
||||
fprintf(stderr, "[SMBBridge] Auth: using workgroup=%s, username=%s, has_password=%s\n",
|
||||
wrapper->workgroup,
|
||||
wrapper->username[0] ? wrapper->username : "(empty)",
|
||||
wrapper->password[0] ? "yes" : "no");
|
||||
|
||||
if (wrapper->workgroup[0]) {
|
||||
strncpy(workgroup, wrapper->workgroup, wgmaxlen - 1);
|
||||
workgroup[wgmaxlen - 1] = '\0';
|
||||
}
|
||||
if (wrapper->username[0]) {
|
||||
strncpy(username, wrapper->username, unmaxlen - 1);
|
||||
username[unmaxlen - 1] = '\0';
|
||||
}
|
||||
if (wrapper->password[0]) {
|
||||
strncpy(password, wrapper->password, pwmaxlen - 1);
|
||||
password[pwmaxlen - 1] = '\0';
|
||||
}
|
||||
} else {
|
||||
fprintf(stderr, "[SMBBridge] Auth: WARNING - no wrapper found!\n");
|
||||
}
|
||||
}
|
||||
|
||||
void* smb_init_context(const char *workgroup,
|
||||
const char *username,
|
||||
const char *password,
|
||||
SMBProtocolVersion version) {
|
||||
fprintf(stderr, "[SMBBridge] Creating new isolated SMB context\n");
|
||||
|
||||
// Allocate context wrapper
|
||||
struct SMBContextWrapper *wrapper = (struct SMBContextWrapper *)calloc(1, sizeof(struct SMBContextWrapper));
|
||||
if (!wrapper) {
|
||||
fprintf(stderr, "[SMBBridge] Failed to allocate context wrapper\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Initialize mutex for this context
|
||||
pthread_mutexattr_t attr;
|
||||
pthread_mutexattr_init(&attr);
|
||||
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
|
||||
pthread_mutex_init(&wrapper->mutex, &attr);
|
||||
pthread_mutexattr_destroy(&attr);
|
||||
|
||||
// Copy credentials
|
||||
strncpy(wrapper->workgroup, workgroup ? workgroup : "WORKGROUP", sizeof(wrapper->workgroup) - 1);
|
||||
strncpy(wrapper->username, username ? username : "", sizeof(wrapper->username) - 1);
|
||||
strncpy(wrapper->password, password ? password : "", sizeof(wrapper->password) - 1);
|
||||
wrapper->version = version;
|
||||
|
||||
// Create NEW isolated libsmbclient context
|
||||
wrapper->ctx = smbc_new_context();
|
||||
if (!wrapper->ctx) {
|
||||
fprintf(stderr, "[SMBBridge] Failed to create libsmbclient context\n");
|
||||
pthread_mutex_destroy(&wrapper->mutex);
|
||||
free(wrapper);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Configure THIS context only (does not affect global state or other contexts)
|
||||
smbc_setFunctionAuthDataWithContext(wrapper->ctx, auth_fn_with_context);
|
||||
smbc_setOptionUserData(wrapper->ctx, wrapper);
|
||||
smbc_setTimeout(wrapper->ctx, 10000); // 10 second timeout
|
||||
smbc_setWorkgroup(wrapper->ctx, wrapper->workgroup);
|
||||
if (wrapper->username[0]) {
|
||||
smbc_setUser(wrapper->ctx, wrapper->username);
|
||||
}
|
||||
|
||||
// Set protocol version BEFORE initialization (must be set before smbc_init_context)
|
||||
// Note: We set min_proto to allow negotiation, and max_proto to limit the highest version
|
||||
if (wrapper->version != SMB_PROTOCOL_AUTO) {
|
||||
const char *min_proto = "NT1"; // Allow negotiation from SMB1
|
||||
const char *max_proto = NULL;
|
||||
switch (wrapper->version) {
|
||||
case SMB_PROTOCOL_SMB1:
|
||||
max_proto = "NT1";
|
||||
min_proto = "NT1"; // Force SMB1 only
|
||||
break;
|
||||
case SMB_PROTOCOL_SMB2:
|
||||
max_proto = "SMB2";
|
||||
break;
|
||||
case SMB_PROTOCOL_SMB3:
|
||||
max_proto = "SMB3";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (max_proto) {
|
||||
fprintf(stderr, "[SMBBridge] Setting protocol range: %s to %s\n", min_proto, max_proto);
|
||||
smbc_bool result = smbc_setOptionProtocols(wrapper->ctx, min_proto, max_proto);
|
||||
fprintf(stderr, "[SMBBridge] smbc_setOptionProtocols returned: %d\n", result);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize context AFTER all options are set
|
||||
if (smbc_init_context(wrapper->ctx) == NULL) {
|
||||
fprintf(stderr, "[SMBBridge] Failed to initialize libsmbclient context\n");
|
||||
smbc_free_context(wrapper->ctx, 0);
|
||||
pthread_mutex_destroy(&wrapper->mutex);
|
||||
free(wrapper);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
fprintf(stderr, "[SMBBridge] Successfully created isolated SMB context (workgroup: %s, user: %s)\n",
|
||||
wrapper->workgroup, wrapper->username[0] ? wrapper->username : "(guest)");
|
||||
|
||||
return (void *)wrapper;
|
||||
}
|
||||
|
||||
void smb_free_context(void *ctx_ptr) {
|
||||
if (!ctx_ptr) return;
|
||||
|
||||
struct SMBContextWrapper *wrapper = (struct SMBContextWrapper *)ctx_ptr;
|
||||
|
||||
fprintf(stderr, "[SMBBridge] Freeing SMB context\n");
|
||||
|
||||
// Lock before cleanup
|
||||
pthread_mutex_lock(&wrapper->mutex);
|
||||
|
||||
// Free libsmbclient context
|
||||
if (wrapper->ctx) {
|
||||
smbc_free_context(wrapper->ctx, 1); // shutdown_ctx = 1
|
||||
wrapper->ctx = NULL;
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&wrapper->mutex);
|
||||
pthread_mutex_destroy(&wrapper->mutex);
|
||||
|
||||
// Clear sensitive data
|
||||
memset(wrapper->password, 0, sizeof(wrapper->password));
|
||||
memset(wrapper->username, 0, sizeof(wrapper->username));
|
||||
|
||||
free(wrapper);
|
||||
|
||||
fprintf(stderr, "[SMBBridge] SMB context freed\n");
|
||||
}
|
||||
|
||||
SMBFileInfo* smb_list_directory(void *ctx_ptr,
|
||||
const char *url,
|
||||
int *count,
|
||||
char **error) {
|
||||
*count = 0;
|
||||
*error = NULL;
|
||||
|
||||
if (!ctx_ptr || !url) {
|
||||
if (error) {
|
||||
*error = strdup("Invalid parameters");
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
struct SMBContextWrapper *wrapper = (struct SMBContextWrapper *)ctx_ptr;
|
||||
|
||||
// Lock THIS context's mutex for thread safety
|
||||
pthread_mutex_lock(&wrapper->mutex);
|
||||
|
||||
fprintf(stderr, "[SMBBridge] Listing directory: %s\n", url);
|
||||
|
||||
// Get context-specific function pointers (avoids smbc_set_context which conflicts with MPV/FFmpeg)
|
||||
smbc_opendir_fn opendir_fn = smbc_getFunctionOpendir(wrapper->ctx);
|
||||
smbc_readdir_fn readdir_fn = smbc_getFunctionReaddir(wrapper->ctx);
|
||||
smbc_closedir_fn closedir_fn = smbc_getFunctionClosedir(wrapper->ctx);
|
||||
smbc_lseekdir_fn lseekdir_fn = smbc_getFunctionLseekdir(wrapper->ctx);
|
||||
smbc_stat_fn stat_fn = smbc_getFunctionStat(wrapper->ctx);
|
||||
|
||||
// Detect if we're listing shares at the server root
|
||||
// URL format: "smb://server/" - no path after server
|
||||
int is_listing_shares = 0;
|
||||
{
|
||||
const char *server_start = strstr(url, "://");
|
||||
if (server_start) {
|
||||
server_start += 3; // Skip "://"
|
||||
const char *first_slash = strchr(server_start, '/');
|
||||
if (first_slash) {
|
||||
const char *path_start = first_slash + 1;
|
||||
if (*path_start == '\0' || (*path_start == '/' && *(path_start + 1) == '\0')) {
|
||||
is_listing_shares = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fprintf(stderr, "[SMBBridge] Listing mode: %s\n", is_listing_shares ? "shares" : "files/dirs");
|
||||
|
||||
// Open directory using context-specific function
|
||||
errno = 0;
|
||||
SMBCFILE *dir = opendir_fn(wrapper->ctx, url);
|
||||
int saved_errno = errno;
|
||||
|
||||
if (!dir) {
|
||||
fprintf(stderr, "[SMBBridge] Failed to open directory: %s (errno: %d)\n",
|
||||
strerror(saved_errno), saved_errno);
|
||||
pthread_mutex_unlock(&wrapper->mutex);
|
||||
if (error) {
|
||||
char err_buf[256];
|
||||
snprintf(err_buf, sizeof(err_buf), "Failed to open directory: %s (errno: %d)",
|
||||
strerror(saved_errno), saved_errno);
|
||||
*error = strdup(err_buf);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// First pass: count valid entries
|
||||
struct smbc_dirent *dirent;
|
||||
int entry_count = 0;
|
||||
while ((dirent = readdir_fn(wrapper->ctx, dir)) != NULL) {
|
||||
// Skip . and ..
|
||||
if (strcmp(dirent->name, ".") == 0 || strcmp(dirent->name, "..") == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_listing_shares) {
|
||||
// When listing shares, only count SMBC_FILE_SHARE (type 3)
|
||||
if (dirent->smbc_type == SMBC_FILE_SHARE) {
|
||||
entry_count++;
|
||||
}
|
||||
} else {
|
||||
// When listing directory contents, count files and directories
|
||||
if (dirent->smbc_type == SMBC_DIR || dirent->smbc_type == SMBC_FILE) {
|
||||
entry_count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty directory is valid (not an error)
|
||||
if (entry_count == 0) {
|
||||
fprintf(stderr, "[SMBBridge] Empty directory\n");
|
||||
closedir_fn(wrapper->ctx, dir);
|
||||
pthread_mutex_unlock(&wrapper->mutex);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
fprintf(stderr, "[SMBBridge] Found %d entries\n", entry_count);
|
||||
|
||||
// Allocate result array
|
||||
SMBFileInfo *files = (SMBFileInfo *)calloc(entry_count, sizeof(SMBFileInfo));
|
||||
if (!files) {
|
||||
fprintf(stderr, "[SMBBridge] Out of memory\n");
|
||||
if (error) {
|
||||
*error = strdup("Out of memory");
|
||||
}
|
||||
closedir_fn(wrapper->ctx, dir);
|
||||
pthread_mutex_unlock(&wrapper->mutex);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Second pass: populate array (seek back to start)
|
||||
lseekdir_fn(wrapper->ctx, dir, 0);
|
||||
|
||||
int i = 0;
|
||||
while ((dirent = readdir_fn(wrapper->ctx, dir)) != NULL && i < entry_count) {
|
||||
// Skip . and ..
|
||||
if (strcmp(dirent->name, ".") == 0 || strcmp(dirent->name, "..") == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter based on listing mode
|
||||
int should_include = 0;
|
||||
if (is_listing_shares) {
|
||||
should_include = (dirent->smbc_type == SMBC_FILE_SHARE);
|
||||
} else {
|
||||
should_include = (dirent->smbc_type == SMBC_DIR || dirent->smbc_type == SMBC_FILE);
|
||||
}
|
||||
|
||||
if (!should_include) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Copy name
|
||||
files[i].name = strdup(dirent->name);
|
||||
files[i].type = dirent->smbc_type;
|
||||
|
||||
// Build full path for stat
|
||||
size_t url_len = strlen(url);
|
||||
size_t name_len = strlen(dirent->name);
|
||||
char *full_path = (char *)malloc(url_len + name_len + 2);
|
||||
if (full_path) {
|
||||
strcpy(full_path, url);
|
||||
if (url[url_len - 1] != '/') {
|
||||
strcat(full_path, "/");
|
||||
}
|
||||
strcat(full_path, dirent->name);
|
||||
|
||||
// Get detailed file info using context-specific function
|
||||
struct stat st;
|
||||
if (stat_fn(wrapper->ctx, full_path, &st) == 0) {
|
||||
files[i].size = st.st_size;
|
||||
files[i].mtime = st.st_mtime;
|
||||
files[i].ctime = st.st_ctime;
|
||||
} else {
|
||||
// If stat fails, use defaults
|
||||
files[i].size = 0;
|
||||
files[i].mtime = 0;
|
||||
files[i].ctime = 0;
|
||||
}
|
||||
|
||||
free(full_path);
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
closedir_fn(wrapper->ctx, dir);
|
||||
|
||||
pthread_mutex_unlock(&wrapper->mutex);
|
||||
|
||||
fprintf(stderr, "[SMBBridge] Directory listing complete. Count: %d\n", i);
|
||||
|
||||
*count = i;
|
||||
return files;
|
||||
}
|
||||
|
||||
void smb_free_file_list(SMBFileInfo *files, int count) {
|
||||
if (!files) return;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
if (files[i].name) {
|
||||
free(files[i].name);
|
||||
}
|
||||
}
|
||||
free(files);
|
||||
}
|
||||
|
||||
int smb_test_connection(void *ctx_ptr, const char *url) {
|
||||
if (!ctx_ptr || !url) {
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
struct SMBContextWrapper *wrapper = (struct SMBContextWrapper *)ctx_ptr;
|
||||
|
||||
// Lock context mutex
|
||||
pthread_mutex_lock(&wrapper->mutex);
|
||||
|
||||
fprintf(stderr, "[SMBBridge] Testing connection to: %s\n", url);
|
||||
|
||||
// Get context-specific function pointers (avoids smbc_set_context which conflicts with MPV/FFmpeg)
|
||||
smbc_opendir_fn opendir_fn = smbc_getFunctionOpendir(wrapper->ctx);
|
||||
smbc_closedir_fn closedir_fn = smbc_getFunctionClosedir(wrapper->ctx);
|
||||
|
||||
// Try to open directory using context-specific function
|
||||
errno = 0;
|
||||
SMBCFILE *dir = opendir_fn(wrapper->ctx, url);
|
||||
int saved_errno = errno;
|
||||
|
||||
if (!dir) {
|
||||
fprintf(stderr, "[SMBBridge] Connection test failed: %s (errno: %d)\n",
|
||||
strerror(saved_errno), saved_errno);
|
||||
pthread_mutex_unlock(&wrapper->mutex);
|
||||
return -saved_errno;
|
||||
}
|
||||
|
||||
closedir_fn(wrapper->ctx, dir);
|
||||
|
||||
pthread_mutex_unlock(&wrapper->mutex);
|
||||
|
||||
fprintf(stderr, "[SMBBridge] Connection test succeeded\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int smb_download_file(void *ctx_ptr, const char *url, const char *local_path, char **error) {
|
||||
*error = NULL;
|
||||
|
||||
if (!ctx_ptr || !url || !local_path) {
|
||||
if (error) {
|
||||
*error = strdup("Invalid parameters");
|
||||
}
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
struct SMBContextWrapper *wrapper = (struct SMBContextWrapper *)ctx_ptr;
|
||||
|
||||
// Lock context mutex
|
||||
pthread_mutex_lock(&wrapper->mutex);
|
||||
|
||||
fprintf(stderr, "[SMBBridge] Downloading file: %s -> %s\n", url, local_path);
|
||||
|
||||
// Get context-specific function pointers (avoids smbc_set_context which conflicts with MPV/FFmpeg)
|
||||
smbc_open_fn open_fn = smbc_getFunctionOpen(wrapper->ctx);
|
||||
smbc_read_fn read_fn = smbc_getFunctionRead(wrapper->ctx);
|
||||
smbc_close_fn close_fn = smbc_getFunctionClose(wrapper->ctx);
|
||||
|
||||
// Open remote file for reading using context-specific function
|
||||
errno = 0;
|
||||
SMBCFILE *file = open_fn(wrapper->ctx, url, O_RDONLY, 0);
|
||||
int saved_errno = errno;
|
||||
|
||||
if (!file) {
|
||||
fprintf(stderr, "[SMBBridge] Failed to open SMB file: %s (errno: %d)\n",
|
||||
strerror(saved_errno), saved_errno);
|
||||
pthread_mutex_unlock(&wrapper->mutex);
|
||||
if (error) {
|
||||
char buf[256];
|
||||
snprintf(buf, sizeof(buf), "Failed to open SMB file: %s (errno: %d)",
|
||||
strerror(saved_errno), saved_errno);
|
||||
*error = strdup(buf);
|
||||
}
|
||||
return -saved_errno;
|
||||
}
|
||||
|
||||
// Open local file for writing
|
||||
FILE *local_file = fopen(local_path, "wb");
|
||||
if (!local_file) {
|
||||
int local_errno = errno;
|
||||
fprintf(stderr, "[SMBBridge] Failed to create local file: %s (errno: %d)\n",
|
||||
strerror(local_errno), local_errno);
|
||||
close_fn(wrapper->ctx, file);
|
||||
pthread_mutex_unlock(&wrapper->mutex);
|
||||
if (error) {
|
||||
char buf[256];
|
||||
snprintf(buf, sizeof(buf), "Failed to create local file: %s (errno: %d)",
|
||||
strerror(local_errno), local_errno);
|
||||
*error = strdup(buf);
|
||||
}
|
||||
return -local_errno;
|
||||
}
|
||||
|
||||
// Read from SMB and write to local file
|
||||
char buffer[8192];
|
||||
ssize_t bytes_read;
|
||||
size_t total_bytes = 0;
|
||||
|
||||
while ((bytes_read = read_fn(wrapper->ctx, file, buffer, sizeof(buffer))) > 0) {
|
||||
size_t bytes_written = fwrite(buffer, 1, bytes_read, local_file);
|
||||
if (bytes_written != (size_t)bytes_read) {
|
||||
// Write error
|
||||
int local_errno = errno;
|
||||
fprintf(stderr, "[SMBBridge] Failed to write to local file (errno: %d)\n", local_errno);
|
||||
fclose(local_file);
|
||||
close_fn(wrapper->ctx, file);
|
||||
pthread_mutex_unlock(&wrapper->mutex);
|
||||
unlink(local_path); // Clean up partial file
|
||||
if (error) {
|
||||
*error = strdup("Failed to write to local file");
|
||||
}
|
||||
return -local_errno;
|
||||
}
|
||||
total_bytes += bytes_written;
|
||||
}
|
||||
|
||||
// Check for read errors
|
||||
if (bytes_read < 0) {
|
||||
int read_errno = errno;
|
||||
fprintf(stderr, "[SMBBridge] Failed to read from SMB: %s\n", strerror(read_errno));
|
||||
fclose(local_file);
|
||||
close_fn(wrapper->ctx, file);
|
||||
pthread_mutex_unlock(&wrapper->mutex);
|
||||
unlink(local_path); // Clean up partial file
|
||||
if (error) {
|
||||
char buf[256];
|
||||
snprintf(buf, sizeof(buf), "Failed to read from SMB: %s", strerror(read_errno));
|
||||
*error = strdup(buf);
|
||||
}
|
||||
return -read_errno;
|
||||
}
|
||||
|
||||
// Clean up
|
||||
fclose(local_file);
|
||||
close_fn(wrapper->ctx, file);
|
||||
pthread_mutex_unlock(&wrapper->mutex);
|
||||
|
||||
fprintf(stderr, "[SMBBridge] Download complete: %zu bytes\n", total_bytes);
|
||||
return 0;
|
||||
}
|
||||
74
Yattee/Services/MediaSources/SMBBridge/SMBBridge.h
Normal file
74
Yattee/Services/MediaSources/SMBBridge/SMBBridge.h
Normal file
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// SMBBridge.h
|
||||
// Yattee
|
||||
//
|
||||
// C bridge to libsmbclient for SMB directory browsing.
|
||||
//
|
||||
|
||||
#ifndef SMBBridge_h
|
||||
#define SMBBridge_h
|
||||
|
||||
#include <sys/types.h>
|
||||
#include <time.h>
|
||||
|
||||
// SMB protocol version options
|
||||
typedef enum {
|
||||
SMB_PROTOCOL_AUTO = 0,
|
||||
SMB_PROTOCOL_SMB1 = 1,
|
||||
SMB_PROTOCOL_SMB2 = 2,
|
||||
SMB_PROTOCOL_SMB3 = 3
|
||||
} SMBProtocolVersion;
|
||||
|
||||
// File information structure for Swift interop
|
||||
typedef struct {
|
||||
char *name; // File/directory name (caller must free)
|
||||
unsigned int type; // SMBC_DIR=7, SMBC_FILE=8
|
||||
off_t size; // File size in bytes
|
||||
time_t mtime; // Modification time
|
||||
time_t ctime; // Creation/change time
|
||||
} SMBFileInfo;
|
||||
|
||||
// Initialize SMB context with authentication and protocol preferences
|
||||
// Returns NULL on failure
|
||||
// Parameters:
|
||||
// workgroup: Workgroup/domain name (e.g., "WORKGROUP")
|
||||
// username: Username for authentication (NULL for guest access)
|
||||
// password: Password for authentication (NULL for guest access)
|
||||
// version: SMB protocol version preference
|
||||
void* smb_init_context(const char *workgroup,
|
||||
const char *username,
|
||||
const char *password,
|
||||
SMBProtocolVersion version);
|
||||
|
||||
// Clean up SMB context and free resources
|
||||
void smb_free_context(void *ctx);
|
||||
|
||||
// List directory contents at given SMB URL
|
||||
// Returns array of SMBFileInfo (caller must free with smb_free_file_list)
|
||||
// Parameters:
|
||||
// ctx: Context from smb_init_context
|
||||
// url: Full SMB URL (e.g., "smb://server/share/path")
|
||||
// count: Output parameter - number of items returned
|
||||
// error: Output parameter - error message if failed (caller must free)
|
||||
SMBFileInfo* smb_list_directory(void *ctx,
|
||||
const char *url,
|
||||
int *count,
|
||||
char **error);
|
||||
|
||||
// Free directory listing returned by smb_list_directory
|
||||
void smb_free_file_list(SMBFileInfo *files, int count);
|
||||
|
||||
// Test connection to SMB URL
|
||||
// Returns 0 on success, negative error code on failure
|
||||
int smb_test_connection(void *ctx, const char *url);
|
||||
|
||||
// Download file from SMB to local path
|
||||
// Returns 0 on success, negative error code on failure
|
||||
// Parameters:
|
||||
// ctx: Context from smb_init_context
|
||||
// url: Full SMB URL (e.g., "smb://server/share/path/file.srt")
|
||||
// local_path: Local filesystem path to write to
|
||||
// error: Output parameter - error message if failed (caller must free)
|
||||
int smb_download_file(void *ctx, const char *url, const char *local_path, char **error);
|
||||
|
||||
#endif /* SMBBridge_h */
|
||||
233
Yattee/Services/MediaSources/SMBBridge/SMBBridgeWrapper.swift
Normal file
233
Yattee/Services/MediaSources/SMBBridge/SMBBridgeWrapper.swift
Normal file
@@ -0,0 +1,233 @@
|
||||
//
|
||||
// SMBBridgeWrapper.swift
|
||||
// Yattee
|
||||
//
|
||||
// Swift wrapper around libsmbclient C bridge for directory browsing.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Swift wrapper for SMB file information from C bridge.
|
||||
struct SMBFileEntry: Sendable {
|
||||
let name: String
|
||||
let isDirectory: Bool
|
||||
let isShare: Bool // True if this is an SMB file share (SMBC_FILE_SHARE)
|
||||
let size: Int64
|
||||
let modifiedDate: Date?
|
||||
let createdDate: Date?
|
||||
}
|
||||
|
||||
/// Error types for SMB bridge operations.
|
||||
enum SMBBridgeError: Error, LocalizedError, Sendable {
|
||||
case contextInitFailed
|
||||
case connectionFailed(String)
|
||||
case listingFailed(String)
|
||||
case invalidURL
|
||||
case invalidParameters
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .contextInitFailed:
|
||||
return "Failed to initialize SMB context"
|
||||
case .connectionFailed(let msg):
|
||||
return "SMB connection failed: \(msg)"
|
||||
case .listingFailed(let msg):
|
||||
return "Failed to list directory: \(msg)"
|
||||
case .invalidURL:
|
||||
return "Invalid SMB URL"
|
||||
case .invalidParameters:
|
||||
return "Invalid parameters provided"
|
||||
}
|
||||
}
|
||||
|
||||
/// User-friendly error messages based on common SMB errors
|
||||
var userFriendlyMessage: String {
|
||||
switch self {
|
||||
case .connectionFailed(let msg) where msg.contains("errno: 13"):
|
||||
return "Permission denied. Check username and password."
|
||||
case .connectionFailed(let msg) where msg.contains("errno: 2"):
|
||||
return "Share or path not found."
|
||||
case .connectionFailed(let msg) where msg.contains("errno: 110"):
|
||||
return "Connection timed out. Check server address and network."
|
||||
case .connectionFailed(let msg) where msg.contains("errno: 111"):
|
||||
return "Cannot reach server. Check server address."
|
||||
case .listingFailed(let msg) where msg.contains("errno: 13"):
|
||||
return "Access denied to this folder."
|
||||
default:
|
||||
return errorDescription ?? "Unknown error"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SMB protocol version for connection preferences (Swift wrapper).
|
||||
enum SMBProtocol: Int32, Codable, Hashable, Sendable, CaseIterable {
|
||||
case auto = 0
|
||||
case smb1 = 1
|
||||
case smb2 = 2
|
||||
case smb3 = 3
|
||||
|
||||
/// Display name for UI
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .auto: return String(localized: "smb.protocol.auto")
|
||||
case .smb1: return "SMB1"
|
||||
case .smb2: return "SMB2"
|
||||
case .smb3: return "SMB3"
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to C enum type
|
||||
var cValue: SMBProtocolVersion {
|
||||
SMBProtocolVersion(UInt32(rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe wrapper around libsmbclient context.
|
||||
actor SMBBridgeContext {
|
||||
private var context: UnsafeMutableRawPointer?
|
||||
private let workgroup: String
|
||||
private let username: String?
|
||||
private let password: String?
|
||||
private let protocolVersion: SMBProtocol
|
||||
|
||||
init(workgroup: String = "WORKGROUP",
|
||||
username: String?,
|
||||
password: String?,
|
||||
protocolVersion: SMBProtocol = SMBProtocol.auto) {
|
||||
self.workgroup = workgroup
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.protocolVersion = protocolVersion
|
||||
}
|
||||
|
||||
/// Initialize the SMB context.
|
||||
func initialize() throws {
|
||||
guard context == nil else { return }
|
||||
|
||||
LoggingService.shared.logMediaSourcesDebug("Initializing SMB context with workgroup: \(self.workgroup), protocol: \(self.protocolVersion.rawValue)")
|
||||
|
||||
let wg = workgroup.cString(using: .utf8)
|
||||
let user = username?.cString(using: .utf8)
|
||||
let pass = password?.cString(using: .utf8)
|
||||
|
||||
// Use the Swift enum's conversion to C enum type
|
||||
context = smb_init_context(wg, user, pass, protocolVersion.cValue)
|
||||
|
||||
if context == nil {
|
||||
LoggingService.shared.logMediaSourcesError("Failed to initialize SMB context")
|
||||
throw SMBBridgeError.contextInitFailed
|
||||
}
|
||||
|
||||
LoggingService.shared.logMediaSources("SMB context initialized successfully")
|
||||
}
|
||||
|
||||
/// List directory contents at given SMB URL.
|
||||
func listDirectory(at url: String) throws -> [SMBFileEntry] {
|
||||
guard let context = context else {
|
||||
throw SMBBridgeError.contextInitFailed
|
||||
}
|
||||
|
||||
LoggingService.shared.logMediaSourcesDebug("Listing SMB directory: \(url)")
|
||||
|
||||
var count: Int32 = 0
|
||||
var errorPtr: UnsafeMutablePointer<CChar>?
|
||||
|
||||
guard let fileList = smb_list_directory(context, url, &count, &errorPtr) else {
|
||||
let errorMsg = errorPtr.map { String(cString: $0) } ?? "Unknown error"
|
||||
if let errorPtr = errorPtr {
|
||||
free(errorPtr)
|
||||
}
|
||||
|
||||
// Empty directory is not an error
|
||||
if count == 0 && errorMsg == "Unknown error" {
|
||||
LoggingService.shared.logMediaSourcesDebug("Directory is empty")
|
||||
return []
|
||||
}
|
||||
|
||||
LoggingService.shared.logMediaSourcesError("Failed to list directory: \(errorMsg)")
|
||||
throw SMBBridgeError.listingFailed(errorMsg)
|
||||
}
|
||||
|
||||
defer { smb_free_file_list(fileList, count) }
|
||||
|
||||
var entries: [SMBFileEntry] = []
|
||||
|
||||
for i in 0..<Int(count) {
|
||||
let fileInfo = fileList[i]
|
||||
let name = String(cString: fileInfo.name)
|
||||
|
||||
// SMBC_FILE_SHARE = 3, SMBC_DIR = 7, SMBC_FILE = 8 (from libsmbclient.h)
|
||||
let isShare = fileInfo.type == 3
|
||||
let isDirectory = fileInfo.type == 7
|
||||
|
||||
let modifiedDate = fileInfo.mtime > 0
|
||||
? Date(timeIntervalSince1970: TimeInterval(fileInfo.mtime))
|
||||
: nil
|
||||
let createdDate = fileInfo.ctime > 0
|
||||
? Date(timeIntervalSince1970: TimeInterval(fileInfo.ctime))
|
||||
: nil
|
||||
|
||||
entries.append(SMBFileEntry(
|
||||
name: name,
|
||||
isDirectory: isDirectory,
|
||||
isShare: isShare,
|
||||
size: Int64(fileInfo.size),
|
||||
modifiedDate: modifiedDate,
|
||||
createdDate: createdDate
|
||||
))
|
||||
}
|
||||
|
||||
LoggingService.shared.logMediaSources("Listed \(entries.count) items from SMB directory")
|
||||
return entries
|
||||
}
|
||||
|
||||
/// Test connection to SMB URL.
|
||||
func testConnection(to url: String) throws {
|
||||
guard let context = context else {
|
||||
throw SMBBridgeError.contextInitFailed
|
||||
}
|
||||
|
||||
LoggingService.shared.logMediaSourcesDebug("Testing SMB connection to: \(url)")
|
||||
|
||||
let result = smb_test_connection(context, url)
|
||||
if result != 0 {
|
||||
let errorMsg = "Connection test failed with error code: \(result)"
|
||||
LoggingService.shared.logMediaSourcesError(errorMsg)
|
||||
throw SMBBridgeError.connectionFailed(errorMsg)
|
||||
}
|
||||
|
||||
LoggingService.shared.logMediaSources("SMB connection test succeeded")
|
||||
}
|
||||
|
||||
/// Download file from SMB to local path.
|
||||
func downloadFile(from url: String, to localPath: String) throws {
|
||||
guard let context = context else {
|
||||
throw SMBBridgeError.contextInitFailed
|
||||
}
|
||||
|
||||
LoggingService.shared.logMediaSourcesDebug("Downloading file from: \(url)")
|
||||
LoggingService.shared.logMediaSourcesDebug("Downloading file to: \(localPath)")
|
||||
|
||||
var errorPtr: UnsafeMutablePointer<CChar>?
|
||||
let result = smb_download_file(context, url, localPath, &errorPtr)
|
||||
|
||||
if result != 0 {
|
||||
let errorMsg = errorPtr.map { String(cString: $0) } ?? "Unknown error"
|
||||
if let errorPtr = errorPtr {
|
||||
free(errorPtr)
|
||||
}
|
||||
LoggingService.shared.logMediaSourcesError("Failed to download file: \(errorMsg)")
|
||||
throw SMBBridgeError.connectionFailed(errorMsg)
|
||||
}
|
||||
|
||||
LoggingService.shared.logMediaSources("File download succeeded")
|
||||
}
|
||||
|
||||
/// Clean up resources.
|
||||
deinit {
|
||||
if let context = context {
|
||||
smb_free_context(context)
|
||||
LoggingService.shared.logMediaSourcesDebug("SMB context cleaned up")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//
|
||||
// libsmbclient_minimal.h
|
||||
// Yattee
|
||||
//
|
||||
// Minimal forward declarations for libsmbclient context-based API to avoid header dependency issues.
|
||||
// This uses the modern context API exported by MPVKit-GPL's Libsmbclient.framework.
|
||||
//
|
||||
|
||||
#ifndef libsmbclient_minimal_h
|
||||
#define libsmbclient_minimal_h
|
||||
|
||||
#include <sys/types.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
|
||||
// SMB entry types (from libsmbclient.h)
|
||||
#define SMBC_WORKGROUP 1
|
||||
#define SMBC_SERVER 2
|
||||
#define SMBC_FILE_SHARE 3
|
||||
#define SMBC_PRINTER_SHARE 4
|
||||
#define SMBC_COMMS_SHARE 5
|
||||
#define SMBC_IPC_SHARE 6
|
||||
#define SMBC_DIR 7
|
||||
#define SMBC_FILE 8
|
||||
#define SMBC_LINK 9
|
||||
|
||||
// Forward declarations for context types
|
||||
typedef struct _SMBCCTX SMBCCTX;
|
||||
typedef struct _SMBCFILE SMBCFILE;
|
||||
|
||||
// Context-specific function pointer types (for true context isolation)
|
||||
// These allow calling SMB functions on a specific context without using smbc_set_context()
|
||||
typedef SMBCFILE * (*smbc_opendir_fn)(SMBCCTX *c, const char *fname);
|
||||
typedef int (*smbc_closedir_fn)(SMBCCTX *c, SMBCFILE *dir);
|
||||
typedef struct smbc_dirent * (*smbc_readdir_fn)(SMBCCTX *c, SMBCFILE *dir);
|
||||
typedef off_t (*smbc_lseekdir_fn)(SMBCCTX *c, SMBCFILE *dir, off_t offset);
|
||||
typedef int (*smbc_stat_fn)(SMBCCTX *c, const char *fname, struct stat *st);
|
||||
typedef SMBCFILE * (*smbc_open_fn)(SMBCCTX *c, const char *fname, int flags, mode_t mode);
|
||||
typedef ssize_t (*smbc_read_fn)(SMBCCTX *c, SMBCFILE *file, void *buf, size_t count);
|
||||
typedef int (*smbc_close_fn)(SMBCCTX *c, SMBCFILE *file);
|
||||
|
||||
// Directory entry structure (from libsmbclient.h)
|
||||
struct smbc_dirent {
|
||||
unsigned int smbc_type;
|
||||
unsigned int dirlen;
|
||||
unsigned int commentlen;
|
||||
char *comment;
|
||||
unsigned int namelen;
|
||||
char name[1]; // Variable length
|
||||
};
|
||||
|
||||
// Auth callback type with context (modern API)
|
||||
typedef void (*smbc_get_auth_data_with_context_fn)(
|
||||
SMBCCTX *ctx,
|
||||
const char *server, const char *share,
|
||||
char *workgroup, int wgmaxlen,
|
||||
char *username, int unmaxlen,
|
||||
char *password, int pwmaxlen
|
||||
);
|
||||
|
||||
// Context management (modern context-based API)
|
||||
extern SMBCCTX *smbc_new_context(void);
|
||||
extern SMBCCTX *smbc_init_context(SMBCCTX *ctx);
|
||||
extern int smbc_free_context(SMBCCTX *ctx, int shutdown_ctx);
|
||||
|
||||
// Context configuration functions
|
||||
extern void smbc_setFunctionAuthDataWithContext(SMBCCTX *ctx, smbc_get_auth_data_with_context_fn fn);
|
||||
extern void smbc_setOptionUserData(SMBCCTX *ctx, void *user_data);
|
||||
extern void *smbc_getOptionUserData(SMBCCTX *ctx);
|
||||
extern void smbc_setTimeout(SMBCCTX *ctx, int timeout);
|
||||
extern void smbc_setWorkgroup(SMBCCTX *ctx, const char *workgroup);
|
||||
extern void smbc_setUser(SMBCCTX *ctx, const char *user);
|
||||
|
||||
// Context-specific function pointer getters (preferred API for multi-context usage)
|
||||
// These provide true context isolation without affecting global state
|
||||
extern smbc_opendir_fn smbc_getFunctionOpendir(SMBCCTX *c);
|
||||
extern smbc_closedir_fn smbc_getFunctionClosedir(SMBCCTX *c);
|
||||
extern smbc_readdir_fn smbc_getFunctionReaddir(SMBCCTX *c);
|
||||
extern smbc_lseekdir_fn smbc_getFunctionLseekdir(SMBCCTX *c);
|
||||
extern smbc_stat_fn smbc_getFunctionStat(SMBCCTX *c);
|
||||
extern smbc_open_fn smbc_getFunctionOpen(SMBCCTX *c);
|
||||
extern smbc_read_fn smbc_getFunctionRead(SMBCCTX *c);
|
||||
extern smbc_close_fn smbc_getFunctionClose(SMBCCTX *c);
|
||||
|
||||
// Boolean type for libsmbclient
|
||||
typedef int smbc_bool;
|
||||
|
||||
// Set SMB protocol version (min/max)
|
||||
extern smbc_bool smbc_setOptionProtocols(SMBCCTX *c, const char *min_proto, const char *max_proto);
|
||||
|
||||
#endif /* libsmbclient_minimal_h */
|
||||
539
Yattee/Services/MediaSources/SMBClient.swift
Normal file
539
Yattee/Services/MediaSources/SMBClient.swift
Normal file
@@ -0,0 +1,539 @@
|
||||
//
|
||||
// SMBClient.swift
|
||||
// Yattee
|
||||
//
|
||||
// SMB/CIFS client for listing and accessing remote files.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Actor-based SMB client for media source operations.
|
||||
actor SMBClient {
|
||||
|
||||
// Cache of SMB bridge contexts per source ID
|
||||
// Reusing contexts avoids creating new ones for each operation
|
||||
private var contextCache: [UUID: SMBBridgeContext] = [:]
|
||||
|
||||
// Track if an operation is in progress per source
|
||||
// If true, new requests will fail fast instead of queueing
|
||||
private var operationInProgress: Set<UUID> = []
|
||||
|
||||
// Callback to check if SMB playback is currently active
|
||||
// When SMB video is playing via MPV/FFmpeg, directory browsing must be blocked
|
||||
// because libsmbclient has internal state conflicts when used concurrently
|
||||
private var isSMBPlaybackActiveCallback: (@Sendable @MainActor () -> Bool)?
|
||||
|
||||
init() {}
|
||||
|
||||
/// Sets the callback to check if SMB playback is active.
|
||||
/// This must be called before using directory listing operations.
|
||||
func setPlaybackActiveCallback(_ callback: @escaping @Sendable @MainActor () -> Bool) {
|
||||
self.isSMBPlaybackActiveCallback = callback
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Constructs a playback URL for an SMB file with embedded credentials.
|
||||
/// - Parameters:
|
||||
/// - file: The media file to construct a URL for.
|
||||
/// - source: The media source configuration.
|
||||
/// - password: The password for authentication (stored separately in Keychain).
|
||||
/// - Returns: A URL with embedded credentials for MPV playback.
|
||||
func constructPlaybackURL(
|
||||
for file: MediaFile,
|
||||
source: MediaSource,
|
||||
password: String?
|
||||
) throws -> URL {
|
||||
guard source.type == .smb else {
|
||||
throw MediaSourceError.unknown("Invalid source type for SMB client")
|
||||
}
|
||||
|
||||
// Extract components from source URL
|
||||
guard let host = source.url.host else {
|
||||
throw MediaSourceError.unknown("SMB source URL missing host")
|
||||
}
|
||||
|
||||
// Extract share from file path (first path component)
|
||||
// file.path format: "ShareName/folder/file.mp4"
|
||||
let pathComponents = file.path.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: true)
|
||||
guard let share = pathComponents.first else {
|
||||
throw MediaSourceError.unknown("File path missing share name")
|
||||
}
|
||||
|
||||
let filePathWithinShare = pathComponents.count > 1 ? String(pathComponents[1]) : ""
|
||||
|
||||
// Percent-encode credentials for URL embedding
|
||||
let encodedUsername = source.username?.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) ?? ""
|
||||
let encodedPassword = password?.addingPercentEncoding(withAllowedCharacters: .urlPasswordAllowed) ?? ""
|
||||
|
||||
// Build SMB URL: smb://user:pass@host/share/path/to/file
|
||||
var urlString = "smb://"
|
||||
|
||||
if !encodedUsername.isEmpty {
|
||||
urlString += encodedUsername
|
||||
if !encodedPassword.isEmpty {
|
||||
urlString += ":" + encodedPassword
|
||||
}
|
||||
urlString += "@"
|
||||
}
|
||||
|
||||
urlString += host
|
||||
urlString += "/" + share
|
||||
|
||||
// Add file path within the share
|
||||
if !filePathWithinShare.isEmpty {
|
||||
urlString += "/" + filePathWithinShare
|
||||
}
|
||||
|
||||
guard let url = URL(string: urlString) else {
|
||||
throw MediaSourceError.unknown("Failed to construct SMB URL")
|
||||
}
|
||||
|
||||
LoggingService.shared.logMediaSourcesDebug("Constructed SMB URL: \(url.sanitized)")
|
||||
return url
|
||||
}
|
||||
|
||||
/// Downloads a subtitle file from SMB to a temporary location.
|
||||
/// - Parameters:
|
||||
/// - file: The subtitle MediaFile to download.
|
||||
/// - source: The media source configuration.
|
||||
/// - password: The password for authentication.
|
||||
/// - videoID: The video ID for organizing temp files.
|
||||
/// - Returns: A local file:// URL to the downloaded subtitle.
|
||||
func downloadSubtitleToTemp(
|
||||
file: MediaFile,
|
||||
source: MediaSource,
|
||||
password: String?,
|
||||
videoID: String
|
||||
) async throws -> URL {
|
||||
guard source.type == .smb else {
|
||||
throw MediaSourceError.unknown("Invalid source type for SMB client")
|
||||
}
|
||||
|
||||
guard file.isSubtitle else {
|
||||
throw MediaSourceError.unknown("File is not a subtitle")
|
||||
}
|
||||
|
||||
LoggingService.shared.logMediaSources("Downloading subtitle: \(file.name)")
|
||||
|
||||
// Get or create cached bridge context for this source
|
||||
let bridge: SMBBridgeContext
|
||||
if let cached = contextCache[source.id] {
|
||||
LoggingService.shared.logMediaSourcesDebug("Using cached SMB context for subtitle download: \(source.id)")
|
||||
bridge = cached
|
||||
} else {
|
||||
LoggingService.shared.logMediaSourcesDebug("Creating new SMB context for subtitle download: \(source.id)")
|
||||
let workgroup = source.smbWorkgroup ?? "WORKGROUP"
|
||||
let protocolVersion = source.smbProtocolVersion ?? .auto
|
||||
|
||||
bridge = SMBBridgeContext(
|
||||
workgroup: workgroup,
|
||||
username: source.username,
|
||||
password: password,
|
||||
protocolVersion: protocolVersion
|
||||
)
|
||||
|
||||
// Initialize the bridge
|
||||
try await bridge.initialize()
|
||||
|
||||
// Cache it for future use
|
||||
contextCache[source.id] = bridge
|
||||
}
|
||||
|
||||
// Construct SMB URL (without credentials - used internally by C bridge)
|
||||
guard let host = source.url.host else {
|
||||
throw MediaSourceError.unknown("SMB source URL missing host")
|
||||
}
|
||||
|
||||
let pathComponents = file.path.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: true)
|
||||
guard let share = pathComponents.first else {
|
||||
throw MediaSourceError.unknown("File path missing share name")
|
||||
}
|
||||
let filePathWithinShare = pathComponents.count > 1 ? String(pathComponents[1]) : ""
|
||||
|
||||
let smbURL = "smb://\(host)/\(share)" + (filePathWithinShare.isEmpty ? "" : "/\(filePathWithinShare)")
|
||||
|
||||
// Create temp directory for this video's subtitles
|
||||
// Use hash of videoID to keep path short (filesystem limits)
|
||||
let videoHash = String(videoID.hashValue)
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("yattee-subtitles", isDirectory: true)
|
||||
.appendingPathComponent(videoHash, isDirectory: true)
|
||||
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
|
||||
// Generate local filename: hash_baseName.extension
|
||||
let localFileName = "\(videoHash)_\(file.baseName).\(file.fileExtension)"
|
||||
let localURL = tempDir.appendingPathComponent(localFileName)
|
||||
|
||||
LoggingService.shared.logMediaSourcesDebug("Downloading from: \(smbURL)")
|
||||
LoggingService.shared.logMediaSourcesDebug("Downloading to: \(localURL.path)")
|
||||
|
||||
// Download the file using the bridge
|
||||
try await bridge.downloadFile(from: smbURL, to: localURL.path)
|
||||
|
||||
LoggingService.shared.logMediaSources("Downloaded subtitle to: \(localURL.path)")
|
||||
return localURL
|
||||
}
|
||||
|
||||
/// Tests the connection to an SMB server by attempting to list shares.
|
||||
/// - Parameters:
|
||||
/// - source: The media source configuration.
|
||||
/// - password: The password for authentication.
|
||||
/// - Returns: True if connection succeeds.
|
||||
func testConnection(
|
||||
source: MediaSource,
|
||||
password: String?
|
||||
) async throws -> Bool {
|
||||
guard source.type == .smb else {
|
||||
throw MediaSourceError.unknown("Invalid source type for SMB client")
|
||||
}
|
||||
|
||||
// Validate URL has required components
|
||||
guard let host = source.url.host else {
|
||||
throw MediaSourceError.unknown("SMB URL missing host")
|
||||
}
|
||||
|
||||
// Validate credentials if username provided
|
||||
if source.username != nil && (password == nil || password!.isEmpty) {
|
||||
throw MediaSourceError.authenticationFailed
|
||||
}
|
||||
|
||||
// Test by attempting to list shares
|
||||
let workgroup = source.smbWorkgroup ?? "WORKGROUP"
|
||||
let protocolVersion = source.smbProtocolVersion ?? .auto
|
||||
|
||||
let bridge = SMBBridgeContext(
|
||||
workgroup: workgroup,
|
||||
username: source.username,
|
||||
password: password,
|
||||
protocolVersion: protocolVersion
|
||||
)
|
||||
|
||||
try await bridge.initialize()
|
||||
try await bridge.testConnection(to: "smb://\(host)/")
|
||||
|
||||
LoggingService.shared.logMediaSources("SMB connection test passed for \(source.url.sanitized)")
|
||||
return true
|
||||
}
|
||||
|
||||
/// Lists files in a directory on an SMB server.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - path: The path to list (relative to source URL).
|
||||
/// - source: The media source configuration.
|
||||
/// - password: The password for authentication (stored separately in Keychain).
|
||||
/// - Returns: Array of files and folders in the directory.
|
||||
func listFiles(
|
||||
at path: String,
|
||||
source: MediaSource,
|
||||
password: String?
|
||||
) async throws -> [MediaFile] {
|
||||
guard source.type == .smb else {
|
||||
throw MediaSourceError.unknown("Invalid source type for SMB client")
|
||||
}
|
||||
|
||||
// Check if SMB playback is active - if so, we cannot use libsmbclient
|
||||
// because it has internal state conflicts with MPV/FFmpeg's concurrent usage
|
||||
if let callback = isSMBPlaybackActiveCallback {
|
||||
let isActive = await callback()
|
||||
if isActive {
|
||||
LoggingService.shared.logMediaSourcesWarning("SMB playback is active, cannot browse directories concurrently")
|
||||
throw MediaSourceError.unknown("Cannot browse SMB while playing video from SMB. Please stop playback first or collapse the browser.")
|
||||
}
|
||||
}
|
||||
|
||||
// If an operation is already in progress for this source, fail fast
|
||||
// This prevents queueing up requests that will timeout
|
||||
if operationInProgress.contains(source.id) {
|
||||
LoggingService.shared.logMediaSourcesWarning("SMB operation already in progress for source \(source.id), skipping request")
|
||||
throw MediaSourceError.unknown("Operation already in progress")
|
||||
}
|
||||
|
||||
// Extract components from source URL
|
||||
guard let host = source.url.host else {
|
||||
throw MediaSourceError.unknown("SMB source URL missing host")
|
||||
}
|
||||
|
||||
// Clean up the path
|
||||
let cleanPath = path.hasPrefix("/") ? String(path.dropFirst()) : path
|
||||
|
||||
// Detect if we're at root level (listing shares) or inside a share (listing files/dirs)
|
||||
let isListingShares = cleanPath.isEmpty || cleanPath == "/"
|
||||
|
||||
// Build SMB URL based on level
|
||||
let urlString: String
|
||||
if isListingShares {
|
||||
// List shares at root: smb://server/
|
||||
urlString = "smb://\(host)/"
|
||||
LoggingService.shared.logMediaSourcesDebug("Listing shares on SMB server: \(urlString)")
|
||||
} else {
|
||||
// List files/dirs within a share: smb://server/share/path
|
||||
urlString = "smb://\(host)/\(cleanPath)"
|
||||
LoggingService.shared.logMediaSourcesDebug("Listing SMB directory: \(urlString)")
|
||||
}
|
||||
|
||||
// Mark operation as in progress
|
||||
operationInProgress.insert(source.id)
|
||||
defer {
|
||||
operationInProgress.remove(source.id)
|
||||
}
|
||||
|
||||
// Check for cancellation before starting
|
||||
try Task.checkCancellation()
|
||||
|
||||
// Get or create cached bridge context for this source
|
||||
let bridge: SMBBridgeContext
|
||||
if let cached = contextCache[source.id] {
|
||||
LoggingService.shared.logMediaSourcesDebug("Using cached SMB context for source: \(source.id)")
|
||||
bridge = cached
|
||||
} else {
|
||||
LoggingService.shared.logMediaSourcesDebug("Creating new SMB context for source: \(source.id)")
|
||||
let workgroup = source.smbWorkgroup ?? "WORKGROUP"
|
||||
let protocolVersion = source.smbProtocolVersion ?? .auto
|
||||
|
||||
bridge = SMBBridgeContext(
|
||||
workgroup: workgroup,
|
||||
username: source.username,
|
||||
password: password,
|
||||
protocolVersion: protocolVersion
|
||||
)
|
||||
|
||||
// Initialize the bridge
|
||||
try await bridge.initialize()
|
||||
|
||||
// Cache it for future use
|
||||
contextCache[source.id] = bridge
|
||||
}
|
||||
|
||||
// Check for cancellation before calling C code
|
||||
try Task.checkCancellation()
|
||||
|
||||
LoggingService.shared.logMediaSourcesDebug("About to call bridge.listDirectory for: \(urlString)")
|
||||
|
||||
// List directory contents
|
||||
let fileEntries = try await bridge.listDirectory(at: urlString)
|
||||
|
||||
LoggingService.shared.logMediaSourcesDebug("bridge.listDirectory returned \(fileEntries.count) entries")
|
||||
|
||||
// Check for empty results when listing shares
|
||||
if isListingShares && fileEntries.isEmpty {
|
||||
throw MediaSourceError.unknown("No accessible shares found on this server. Check credentials and permissions.")
|
||||
}
|
||||
|
||||
// Convert to MediaFile array
|
||||
let mediaFiles = fileEntries.map { entry -> MediaFile in
|
||||
let fullPath = cleanPath.isEmpty ? entry.name : "\(cleanPath)/\(entry.name)"
|
||||
|
||||
return MediaFile(
|
||||
source: source,
|
||||
path: fullPath,
|
||||
name: entry.name,
|
||||
isDirectory: entry.isDirectory || entry.isShare, // Shares are treated as directories
|
||||
isShare: entry.isShare,
|
||||
size: entry.size,
|
||||
modifiedDate: entry.modifiedDate,
|
||||
createdDate: entry.createdDate
|
||||
)
|
||||
}
|
||||
|
||||
LoggingService.shared.logMediaSources("Listed \(mediaFiles.count) \(isListingShares ? "shares" : "files") from SMB: \(urlString)")
|
||||
return mediaFiles
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
/// Validates SMB URL format.
|
||||
private func validateSMBURL(_ url: URL) throws {
|
||||
guard url.scheme?.lowercased() == "smb" else {
|
||||
throw MediaSourceError.unknown("URL must use smb:// scheme")
|
||||
}
|
||||
|
||||
guard url.host != nil else {
|
||||
throw MediaSourceError.unknown("SMB URL missing host")
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears cached SMB context for a specific source.
|
||||
/// Call this when source credentials change or connection fails.
|
||||
func clearCache(for source: MediaSource) {
|
||||
contextCache.removeValue(forKey: source.id)
|
||||
LoggingService.shared.logMediaSourcesDebug("Cleared SMB context cache for source: \(source.id)")
|
||||
}
|
||||
|
||||
/// Clears all cached SMB contexts.
|
||||
func clearAllCaches() {
|
||||
contextCache.removeAll()
|
||||
LoggingService.shared.logMediaSourcesDebug("Cleared all SMB context caches")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File Download
|
||||
|
||||
extension SMBClient {
|
||||
/// Downloads a file from SMB to the specified downloads directory.
|
||||
/// - Parameters:
|
||||
/// - filePath: The file path within the source (e.g., "ShareName/folder/file.mp4").
|
||||
/// - source: The media source configuration.
|
||||
/// - password: The password for authentication.
|
||||
/// - downloadsDirectory: The directory to save the file to.
|
||||
/// - progressHandler: Optional callback for progress updates (bytes downloaded, total bytes if known).
|
||||
/// - Returns: The local file URL and file size.
|
||||
func downloadFileToDownloads(
|
||||
filePath: String,
|
||||
source: MediaSource,
|
||||
password: String?,
|
||||
downloadsDirectory: URL,
|
||||
progressHandler: (@Sendable (Int64, Int64?) -> Void)? = nil
|
||||
) async throws -> (localURL: URL, fileSize: Int64) {
|
||||
guard source.type == .smb else {
|
||||
throw MediaSourceError.unknown("Invalid source type for SMB client")
|
||||
}
|
||||
|
||||
LoggingService.shared.logMediaSources("Downloading SMB file: \(filePath)")
|
||||
|
||||
// Get or create cached bridge context for this source
|
||||
let bridge: SMBBridgeContext
|
||||
if let cached = contextCache[source.id] {
|
||||
LoggingService.shared.logMediaSourcesDebug("Using cached SMB context for download: \(source.id)")
|
||||
bridge = cached
|
||||
} else {
|
||||
LoggingService.shared.logMediaSourcesDebug("Creating new SMB context for download: \(source.id)")
|
||||
let workgroup = source.smbWorkgroup ?? "WORKGROUP"
|
||||
let protocolVersion = source.smbProtocolVersion ?? .auto
|
||||
|
||||
bridge = SMBBridgeContext(
|
||||
workgroup: workgroup,
|
||||
username: source.username,
|
||||
password: password,
|
||||
protocolVersion: protocolVersion
|
||||
)
|
||||
|
||||
// Initialize the bridge
|
||||
try await bridge.initialize()
|
||||
|
||||
// Cache it for future use
|
||||
contextCache[source.id] = bridge
|
||||
}
|
||||
|
||||
// Construct SMB URL (without credentials - used internally by C bridge)
|
||||
guard let host = source.url.host else {
|
||||
throw MediaSourceError.unknown("SMB source URL missing host")
|
||||
}
|
||||
|
||||
let pathComponents = filePath.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: true)
|
||||
guard let share = pathComponents.first else {
|
||||
throw MediaSourceError.unknown("File path missing share name")
|
||||
}
|
||||
let filePathWithinShare = pathComponents.count > 1 ? String(pathComponents[1]) : ""
|
||||
|
||||
let smbURL = "smb://\(host)/\(share)" + (filePathWithinShare.isEmpty ? "" : "/\(filePathWithinShare)")
|
||||
|
||||
// Generate local filename from SMB path
|
||||
let fileName = URL(fileURLWithPath: filePath).lastPathComponent
|
||||
|
||||
// Ensure unique filename if file already exists
|
||||
var localURL = downloadsDirectory.appendingPathComponent(fileName)
|
||||
localURL = uniqueDestinationURL(for: localURL)
|
||||
|
||||
LoggingService.shared.logMediaSourcesDebug("Downloading from: \(smbURL)")
|
||||
LoggingService.shared.logMediaSourcesDebug("Downloading to: \(localURL.path)")
|
||||
|
||||
// Download the file using the bridge
|
||||
try await bridge.downloadFile(from: smbURL, to: localURL.path)
|
||||
|
||||
// Get file size after download
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: localURL.path)
|
||||
let fileSize = attrs[.size] as? Int64 ?? 0
|
||||
|
||||
LoggingService.shared.logMediaSources("Downloaded SMB file to: \(localURL.path), size: \(fileSize)")
|
||||
return (localURL, fileSize)
|
||||
}
|
||||
|
||||
/// Generates a unique file URL by appending numbers if the file already exists.
|
||||
private func uniqueDestinationURL(for url: URL) -> URL {
|
||||
let fileManager = FileManager.default
|
||||
guard fileManager.fileExists(atPath: url.path) else {
|
||||
return url
|
||||
}
|
||||
|
||||
let directory = url.deletingLastPathComponent()
|
||||
let baseName = url.deletingPathExtension().lastPathComponent
|
||||
let fileExtension = url.pathExtension
|
||||
|
||||
var counter = 1
|
||||
var newURL = url
|
||||
|
||||
while fileManager.fileExists(atPath: newURL.path) {
|
||||
let newName = fileExtension.isEmpty
|
||||
? "\(baseName) (\(counter))"
|
||||
: "\(baseName) (\(counter)).\(fileExtension)"
|
||||
newURL = directory.appendingPathComponent(newName)
|
||||
counter += 1
|
||||
}
|
||||
|
||||
return newURL
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bandwidth Testing
|
||||
|
||||
extension SMBClient {
|
||||
|
||||
/// Tests bandwidth to an SMB server.
|
||||
///
|
||||
/// Note: This is a placeholder implementation.
|
||||
/// Real bandwidth testing would require file upload/download operations.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - source: The media source configuration.
|
||||
/// - password: The password for authentication.
|
||||
/// - testFileSizeMB: Size of test file in megabytes.
|
||||
/// - progressHandler: Optional callback for progress updates.
|
||||
/// - Returns: BandwidthTestResult with speed measurements (same type as WebDAV).
|
||||
func testBandwidth(
|
||||
source: MediaSource,
|
||||
password: String?,
|
||||
testFileSizeMB: Int = 5,
|
||||
progressHandler: (@Sendable (String) -> Void)? = nil
|
||||
) async throws -> BandwidthTestResult {
|
||||
guard source.type == .smb else {
|
||||
throw MediaSourceError.unknown("Invalid source type for SMB client")
|
||||
}
|
||||
|
||||
progressHandler?("Connecting...")
|
||||
|
||||
// Validate connection
|
||||
_ = try await testConnection(source: source, password: password)
|
||||
|
||||
progressHandler?("Complete")
|
||||
|
||||
// TODO: Implement actual bandwidth testing
|
||||
LoggingService.shared.logMediaSourcesWarning("SMB bandwidth testing not yet implemented")
|
||||
|
||||
return BandwidthTestResult(
|
||||
hasWriteAccess: false,
|
||||
uploadSpeed: nil,
|
||||
downloadSpeed: nil,
|
||||
testFileSize: 0,
|
||||
warning: "Bandwidth testing not available for SMB sources"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - URL Extension for Sanitization
|
||||
|
||||
extension URL {
|
||||
/// Returns a sanitized URL string with credentials hidden.
|
||||
/// Used for secure logging without exposing passwords.
|
||||
var sanitized: String {
|
||||
var components = URLComponents(url: self, resolvingAgainstBaseURL: false)
|
||||
if components?.user != nil {
|
||||
components?.user = "***"
|
||||
}
|
||||
if components?.password != nil {
|
||||
components?.password = "***"
|
||||
}
|
||||
return components?.string ?? absoluteString
|
||||
}
|
||||
}
|
||||
704
Yattee/Services/MediaSources/WebDAVClient.swift
Normal file
704
Yattee/Services/MediaSources/WebDAVClient.swift
Normal file
@@ -0,0 +1,704 @@
|
||||
//
|
||||
// WebDAVClient.swift
|
||||
// Yattee
|
||||
//
|
||||
// WebDAV client for listing and accessing remote files.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Actor-based WebDAV client for media source operations.
|
||||
actor WebDAVClient {
|
||||
private let session: URLSession
|
||||
|
||||
init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Lists files in a directory on a WebDAV server.
|
||||
/// - Parameters:
|
||||
/// - path: The path to list (relative to source URL).
|
||||
/// - source: The media source configuration.
|
||||
/// - password: The password for authentication (stored separately in Keychain).
|
||||
/// - Returns: Array of files and folders in the directory.
|
||||
func listFiles(
|
||||
at path: String,
|
||||
source: MediaSource,
|
||||
password: String?
|
||||
) async throws -> [MediaFile] {
|
||||
guard source.type == .webdav else {
|
||||
throw MediaSourceError.unknown("Invalid source type for WebDAV client")
|
||||
}
|
||||
|
||||
let normalizedPath = path.hasPrefix("/") ? path : "/\(path)"
|
||||
let requestURL = source.url.appendingPathComponent(normalizedPath)
|
||||
|
||||
var request = URLRequest(url: requestURL)
|
||||
request.httpMethod = "PROPFIND"
|
||||
request.setValue("1", forHTTPHeaderField: "Depth")
|
||||
request.setValue("application/xml", forHTTPHeaderField: "Content-Type")
|
||||
request.timeoutInterval = 30
|
||||
|
||||
// Add authentication header
|
||||
if let authHeader = buildAuthHeader(username: source.username, password: password) {
|
||||
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
// PROPFIND request body
|
||||
request.httpBody = propfindRequestBody.data(using: .utf8)
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
|
||||
do {
|
||||
(data, response) = try await session.data(for: request)
|
||||
} catch let error as URLError {
|
||||
throw mapURLError(error)
|
||||
} catch {
|
||||
throw MediaSourceError.connectionFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
// Validate response
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200...299, 207: // 207 Multi-Status is standard WebDAV success
|
||||
break
|
||||
case 401:
|
||||
// Log auth failure details for debugging
|
||||
let wwwAuth = httpResponse.allHeaderFields["WWW-Authenticate"] as? String ?? "none"
|
||||
LoggingService.shared.logMediaSourcesError("WebDAV auth failed", error: nil)
|
||||
LoggingService.shared.logMediaSourcesDebug("WebDAV auth details: URL=\(requestURL.absoluteString), WWW-Authenticate=\(wwwAuth), username=\(source.username ?? "nil"), hasPassword=\(password != nil && !password!.isEmpty)")
|
||||
throw MediaSourceError.authenticationFailed
|
||||
case 404:
|
||||
throw MediaSourceError.pathNotFound(path)
|
||||
default:
|
||||
throw MediaSourceError.connectionFailed("HTTP \(httpResponse.statusCode)")
|
||||
}
|
||||
}
|
||||
|
||||
// Parse XML response
|
||||
return try parseMultiStatusResponse(data, source: source, basePath: normalizedPath)
|
||||
}
|
||||
|
||||
/// Tests the connection to a WebDAV server.
|
||||
/// - Parameters:
|
||||
/// - source: The media source configuration.
|
||||
/// - password: The password for authentication.
|
||||
/// - Returns: True if connection is successful.
|
||||
func testConnection(
|
||||
source: MediaSource,
|
||||
password: String?
|
||||
) async throws -> Bool {
|
||||
_ = try await listFiles(at: "/", source: source, password: password)
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Bandwidth Testing
|
||||
|
||||
/// Tests bandwidth to a WebDAV server with auto-detection of write access.
|
||||
/// - Parameters:
|
||||
/// - source: The media source configuration.
|
||||
/// - password: The password for authentication.
|
||||
/// - testFileSizeMB: Size of test file in megabytes (default 5 MB).
|
||||
/// - progressHandler: Optional callback for progress updates (status string).
|
||||
/// - Returns: BandwidthTestResult with speed measurements.
|
||||
func testBandwidth(
|
||||
source: MediaSource,
|
||||
password: String?,
|
||||
testFileSizeMB: Int = 20,
|
||||
progressHandler: (@Sendable (String) -> Void)? = nil
|
||||
) async throws -> BandwidthTestResult {
|
||||
let bandwidthTestSize = Int64(testFileSizeMB) * 1024 * 1024
|
||||
guard source.type == .webdav else {
|
||||
throw MediaSourceError.unknown("Invalid source type for WebDAV client")
|
||||
}
|
||||
|
||||
// First, verify basic connectivity
|
||||
progressHandler?("Connecting...")
|
||||
_ = try await listFiles(at: "/", source: source, password: password)
|
||||
|
||||
// Try write test first
|
||||
do {
|
||||
return try await performWriteTest(source: source, password: password, testSize: bandwidthTestSize, progressHandler: progressHandler)
|
||||
} catch let error as MediaSourceError {
|
||||
// Check if it's a permission error - fall back to read-only
|
||||
if case .connectionFailed(let message) = error,
|
||||
message.contains("403") || message.contains("405") || message.contains("401") {
|
||||
return try await performReadOnlyTest(source: source, password: password, testSize: bandwidthTestSize, progressHandler: progressHandler)
|
||||
}
|
||||
throw error
|
||||
} catch {
|
||||
// For other errors, try read-only test
|
||||
return try await performReadOnlyTest(source: source, password: password, testSize: bandwidthTestSize, progressHandler: progressHandler)
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs a write test: upload, download, and delete a test file.
|
||||
private func performWriteTest(
|
||||
source: MediaSource,
|
||||
password: String?,
|
||||
testSize: Int64,
|
||||
progressHandler: (@Sendable (String) -> Void)?
|
||||
) async throws -> BandwidthTestResult {
|
||||
let testFileName = ".yattee-bandwidth-test-\(UUID().uuidString).tmp"
|
||||
|
||||
// Find a writable location - try first subfolder (root may not be writable, e.g. Synology shares listing)
|
||||
let writablePath = try await findWritablePath(source: source, password: password)
|
||||
let testPath = writablePath + testFileName
|
||||
|
||||
// Generate test data (zeros are fine for bandwidth testing)
|
||||
let testData = Data(count: Int(testSize))
|
||||
|
||||
// Upload test
|
||||
progressHandler?("Uploading...")
|
||||
let uploadStart = CFAbsoluteTimeGetCurrent()
|
||||
try await uploadFile(data: testData, to: testPath, source: source, password: password)
|
||||
let uploadDuration = CFAbsoluteTimeGetCurrent() - uploadStart
|
||||
let uploadSpeed = Double(testSize) / uploadDuration
|
||||
|
||||
progressHandler?("Downloading...")
|
||||
|
||||
// Download test
|
||||
let downloadStart = CFAbsoluteTimeGetCurrent()
|
||||
_ = try await downloadFile(from: testPath, source: source, password: password)
|
||||
let downloadDuration = CFAbsoluteTimeGetCurrent() - downloadStart
|
||||
let downloadSpeed = Double(testSize) / downloadDuration
|
||||
|
||||
progressHandler?("Cleaning up...")
|
||||
|
||||
// Delete test file (ignore errors - cleanup is best effort)
|
||||
try? await deleteFile(at: testPath, source: source, password: password)
|
||||
|
||||
progressHandler?("Complete")
|
||||
|
||||
return BandwidthTestResult(
|
||||
hasWriteAccess: true,
|
||||
uploadSpeed: uploadSpeed,
|
||||
downloadSpeed: downloadSpeed,
|
||||
testFileSize: testSize,
|
||||
warning: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Performs a read-only test by finding and downloading an existing file.
|
||||
private func performReadOnlyTest(
|
||||
source: MediaSource,
|
||||
password: String?,
|
||||
testSize: Int64,
|
||||
progressHandler: (@Sendable (String) -> Void)?
|
||||
) async throws -> BandwidthTestResult {
|
||||
progressHandler?("Finding test file...")
|
||||
|
||||
// Find a file to download
|
||||
guard let testFile = try await findTestFile(source: source, password: password) else {
|
||||
// Server is empty or has no accessible files
|
||||
progressHandler?("Complete")
|
||||
return BandwidthTestResult(
|
||||
hasWriteAccess: false,
|
||||
uploadSpeed: nil,
|
||||
downloadSpeed: nil,
|
||||
testFileSize: 0,
|
||||
warning: "No files available for speed test"
|
||||
)
|
||||
}
|
||||
|
||||
progressHandler?("Downloading...")
|
||||
|
||||
// Download the file (or first N MB of it based on test size)
|
||||
let downloadStart = CFAbsoluteTimeGetCurrent()
|
||||
let downloadedSize = try await downloadFilePartial(
|
||||
from: testFile.path,
|
||||
source: source,
|
||||
password: password,
|
||||
maxBytes: testSize
|
||||
)
|
||||
let downloadDuration = CFAbsoluteTimeGetCurrent() - downloadStart
|
||||
let downloadSpeed = Double(downloadedSize) / downloadDuration
|
||||
|
||||
progressHandler?("Complete")
|
||||
|
||||
return BandwidthTestResult(
|
||||
hasWriteAccess: false,
|
||||
uploadSpeed: nil,
|
||||
downloadSpeed: downloadSpeed,
|
||||
testFileSize: Int64(downloadedSize),
|
||||
warning: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Finds a writable path for the bandwidth test file.
|
||||
/// Tries root first, then first available subfolder (useful for Synology where root is shares listing).
|
||||
private func findWritablePath(
|
||||
source: MediaSource,
|
||||
password: String?
|
||||
) async throws -> String {
|
||||
// List root to find first subfolder
|
||||
let rootFiles = try await listFiles(at: "/", source: source, password: password)
|
||||
|
||||
// Try first directory as writable location
|
||||
if let firstDir = rootFiles.first(where: { $0.isDirectory }) {
|
||||
return firstDir.path.hasSuffix("/") ? firstDir.path : firstDir.path + "/"
|
||||
}
|
||||
|
||||
// Fall back to root if no subdirectories
|
||||
return "/"
|
||||
}
|
||||
|
||||
/// Finds a suitable file for download testing.
|
||||
private func findTestFile(
|
||||
source: MediaSource,
|
||||
password: String?
|
||||
) async throws -> MediaFile? {
|
||||
return try await findFileRecursive(
|
||||
in: "/",
|
||||
source: source,
|
||||
password: password,
|
||||
depth: 0,
|
||||
maxDepth: 2
|
||||
)
|
||||
}
|
||||
|
||||
/// Recursively searches for a suitable test file.
|
||||
private func findFileRecursive(
|
||||
in path: String,
|
||||
source: MediaSource,
|
||||
password: String?,
|
||||
depth: Int,
|
||||
maxDepth: Int
|
||||
) async throws -> MediaFile? {
|
||||
let files = try await listFiles(at: path, source: source, password: password)
|
||||
|
||||
// First, look for any file with reasonable size (> 100KB)
|
||||
if let file = files.first(where: { !$0.isDirectory && ($0.size ?? 0) > 100_000 }) {
|
||||
return file
|
||||
}
|
||||
|
||||
// If at max depth, just return any file
|
||||
if depth >= maxDepth {
|
||||
return files.first(where: { !$0.isDirectory })
|
||||
}
|
||||
|
||||
// Otherwise, recurse into directories
|
||||
for dir in files.filter({ $0.isDirectory }) {
|
||||
if let file = try? await findFileRecursive(
|
||||
in: dir.path,
|
||||
source: source,
|
||||
password: password,
|
||||
depth: depth + 1,
|
||||
maxDepth: maxDepth
|
||||
) {
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - WebDAV Operations for Bandwidth Test
|
||||
|
||||
/// Uploads data to a WebDAV server.
|
||||
private func uploadFile(
|
||||
data: Data,
|
||||
to path: String,
|
||||
source: MediaSource,
|
||||
password: String?
|
||||
) async throws {
|
||||
let normalizedPath = path.hasPrefix("/") ? path : "/\(path)"
|
||||
let requestURL = source.url.appendingPathComponent(normalizedPath)
|
||||
|
||||
var request = URLRequest(url: requestURL)
|
||||
request.httpMethod = "PUT"
|
||||
request.httpBody = data
|
||||
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("\(data.count)", forHTTPHeaderField: "Content-Length")
|
||||
request.timeoutInterval = 120 // 2 minutes for upload
|
||||
|
||||
if let authHeader = buildAuthHeader(username: source.username, password: password) {
|
||||
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
let (_, response) = try await session.data(for: request)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
guard (200...299).contains(httpResponse.statusCode) || httpResponse.statusCode == 201 else {
|
||||
throw MediaSourceError.connectionFailed("Upload failed: HTTP \(httpResponse.statusCode)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads a file from a WebDAV server.
|
||||
private func downloadFile(
|
||||
from path: String,
|
||||
source: MediaSource,
|
||||
password: String?
|
||||
) async throws -> Data {
|
||||
let normalizedPath = path.hasPrefix("/") ? path : "/\(path)"
|
||||
let requestURL = source.url.appendingPathComponent(normalizedPath)
|
||||
|
||||
var request = URLRequest(url: requestURL)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = 120
|
||||
|
||||
if let authHeader = buildAuthHeader(username: source.username, password: password) {
|
||||
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
guard (200...299).contains(httpResponse.statusCode) else {
|
||||
throw MediaSourceError.connectionFailed("Download failed: HTTP \(httpResponse.statusCode)")
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/// Downloads up to maxBytes of a file (using Range header if supported).
|
||||
private func downloadFilePartial(
|
||||
from path: String,
|
||||
source: MediaSource,
|
||||
password: String?,
|
||||
maxBytes: Int64
|
||||
) async throws -> Int {
|
||||
let normalizedPath = path.hasPrefix("/") ? path : "/\(path)"
|
||||
let requestURL = source.url.appendingPathComponent(normalizedPath)
|
||||
|
||||
var request = URLRequest(url: requestURL)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("bytes=0-\(maxBytes - 1)", forHTTPHeaderField: "Range")
|
||||
request.timeoutInterval = 120
|
||||
|
||||
if let authHeader = buildAuthHeader(username: source.username, password: password) {
|
||||
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
// Accept 200 (full file) or 206 (partial content)
|
||||
guard httpResponse.statusCode == 200 || httpResponse.statusCode == 206 else {
|
||||
throw MediaSourceError.connectionFailed("Download failed: HTTP \(httpResponse.statusCode)")
|
||||
}
|
||||
}
|
||||
|
||||
return data.count
|
||||
}
|
||||
|
||||
/// Deletes a file from a WebDAV server.
|
||||
private func deleteFile(
|
||||
at path: String,
|
||||
source: MediaSource,
|
||||
password: String?
|
||||
) async throws {
|
||||
let normalizedPath = path.hasPrefix("/") ? path : "/\(path)"
|
||||
let requestURL = source.url.appendingPathComponent(normalizedPath)
|
||||
|
||||
var request = URLRequest(url: requestURL)
|
||||
request.httpMethod = "DELETE"
|
||||
request.timeoutInterval = 30
|
||||
|
||||
if let authHeader = buildAuthHeader(username: source.username, password: password) {
|
||||
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
let (_, response) = try await session.data(for: request)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
// Accept 200, 204 (No Content), or 404 (already gone)
|
||||
guard (200...299).contains(httpResponse.statusCode) || httpResponse.statusCode == 404 else {
|
||||
throw MediaSourceError.connectionFailed("Delete failed: HTTP \(httpResponse.statusCode)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds authentication headers for a WebDAV request.
|
||||
/// - Parameters:
|
||||
/// - source: The media source.
|
||||
/// - password: The password from Keychain.
|
||||
/// - Returns: Dictionary of HTTP headers for authentication.
|
||||
func authHeaders(
|
||||
for source: MediaSource,
|
||||
password: String?
|
||||
) -> [String: String]? {
|
||||
guard let authHeader = buildAuthHeader(username: source.username, password: password) else {
|
||||
return nil
|
||||
}
|
||||
return ["Authorization": authHeader]
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func buildAuthHeader(username: String?, password: String?) -> String? {
|
||||
guard let username, !username.isEmpty else { return nil }
|
||||
let credentials = "\(username):\(password ?? "")"
|
||||
guard let data = credentials.data(using: .utf8) else { return nil }
|
||||
return "Basic \(data.base64EncodedString())"
|
||||
}
|
||||
|
||||
private func mapURLError(_ error: URLError) -> MediaSourceError {
|
||||
switch error.code {
|
||||
case .timedOut:
|
||||
return .timeout
|
||||
case .notConnectedToInternet, .networkConnectionLost:
|
||||
return .noConnection
|
||||
case .userAuthenticationRequired:
|
||||
return .authenticationFailed
|
||||
default:
|
||||
return .connectionFailed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - XML Parsing
|
||||
|
||||
/// PROPFIND request body asking for file properties.
|
||||
private let propfindRequestBody = """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:displayname/>
|
||||
<D:getcontentlength/>
|
||||
<D:getlastmodified/>
|
||||
<D:getcontenttype/>
|
||||
<D:resourcetype/>
|
||||
</D:prop>
|
||||
</D:propfind>
|
||||
"""
|
||||
|
||||
private func parseMultiStatusResponse(
|
||||
_ data: Data,
|
||||
source: MediaSource,
|
||||
basePath: String
|
||||
) throws -> [MediaFile] {
|
||||
let parser = WebDAVResponseParser(source: source, basePath: basePath)
|
||||
return try parser.parse(data)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bandwidth Test Result
|
||||
|
||||
/// Result of a bandwidth test on a WebDAV server.
|
||||
struct BandwidthTestResult: Sendable {
|
||||
/// Whether the server allows write access (upload/delete).
|
||||
let hasWriteAccess: Bool
|
||||
|
||||
/// Upload speed in bytes per second (nil if write access unavailable).
|
||||
let uploadSpeed: Double?
|
||||
|
||||
/// Download speed in bytes per second.
|
||||
let downloadSpeed: Double?
|
||||
|
||||
/// Size of the test file used (in bytes).
|
||||
let testFileSize: Int64
|
||||
|
||||
/// Any warning message (e.g., "Server appears empty, could not test download speed").
|
||||
let warning: String?
|
||||
|
||||
/// Formatted upload speed string (e.g., "12.5 MB/s").
|
||||
var formattedUploadSpeed: String? {
|
||||
guard let speed = uploadSpeed else { return nil }
|
||||
return Self.formatSpeed(speed)
|
||||
}
|
||||
|
||||
/// Formatted download speed string (e.g., "45.2 MB/s").
|
||||
var formattedDownloadSpeed: String? {
|
||||
guard let speed = downloadSpeed else { return nil }
|
||||
return Self.formatSpeed(speed)
|
||||
}
|
||||
|
||||
private static func formatSpeed(_ bytesPerSecond: Double) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .file
|
||||
formatter.allowedUnits = [.useKB, .useMB, .useGB]
|
||||
return formatter.string(fromByteCount: Int64(bytesPerSecond)) + "/s"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WebDAV Response Parser
|
||||
|
||||
/// Parses WebDAV PROPFIND multi-status XML responses.
|
||||
private final class WebDAVResponseParser: NSObject, XMLParserDelegate {
|
||||
private let source: MediaSource
|
||||
private let basePath: String
|
||||
|
||||
private var files: [MediaFile] = []
|
||||
private var currentResponse: ResponseData?
|
||||
private var currentElement: String = ""
|
||||
private var currentText: String = ""
|
||||
|
||||
// Temporary storage for current response properties
|
||||
private struct ResponseData {
|
||||
var href: String = ""
|
||||
var displayName: String?
|
||||
var contentLength: Int64?
|
||||
var lastModified: Date?
|
||||
var contentType: String?
|
||||
var isCollection: Bool = false
|
||||
}
|
||||
|
||||
init(source: MediaSource, basePath: String) {
|
||||
self.source = source
|
||||
self.basePath = basePath
|
||||
}
|
||||
|
||||
func parse(_ data: Data) throws -> [MediaFile] {
|
||||
let parser = XMLParser(data: data)
|
||||
parser.delegate = self
|
||||
parser.shouldProcessNamespaces = true
|
||||
|
||||
guard parser.parse() else {
|
||||
if let error = parser.parserError {
|
||||
throw MediaSourceError.parsingFailed(error.localizedDescription)
|
||||
}
|
||||
throw MediaSourceError.parsingFailed("Unknown XML parsing error")
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// MARK: - XMLParserDelegate
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser,
|
||||
didStartElement elementName: String,
|
||||
namespaceURI: String?,
|
||||
qualifiedName: String?,
|
||||
attributes: [String: String]
|
||||
) {
|
||||
currentElement = elementName
|
||||
currentText = ""
|
||||
|
||||
if elementName == "response" {
|
||||
currentResponse = ResponseData()
|
||||
} else if elementName == "collection" {
|
||||
currentResponse?.isCollection = true
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
currentText += string
|
||||
}
|
||||
|
||||
func parser(
|
||||
_ parser: XMLParser,
|
||||
didEndElement elementName: String,
|
||||
namespaceURI: String?,
|
||||
qualifiedName: String?
|
||||
) {
|
||||
let text = currentText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
switch elementName {
|
||||
case "href":
|
||||
currentResponse?.href = text
|
||||
case "displayname":
|
||||
if !text.isEmpty {
|
||||
currentResponse?.displayName = text
|
||||
}
|
||||
case "getcontentlength":
|
||||
currentResponse?.contentLength = Int64(text)
|
||||
case "getlastmodified":
|
||||
currentResponse?.lastModified = parseHTTPDate(text)
|
||||
case "getcontenttype":
|
||||
if !text.isEmpty {
|
||||
currentResponse?.contentType = text
|
||||
}
|
||||
case "response":
|
||||
if let response = currentResponse {
|
||||
if let file = createMediaFile(from: response) {
|
||||
files.append(file)
|
||||
}
|
||||
}
|
||||
currentResponse = nil
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
currentText = ""
|
||||
}
|
||||
|
||||
private func createMediaFile(from response: ResponseData) -> MediaFile? {
|
||||
// Decode URL-encoded path
|
||||
let href = response.href.removingPercentEncoding ?? response.href
|
||||
|
||||
// Extract path relative to source URL
|
||||
var path = href
|
||||
if let sourceHost = source.url.host {
|
||||
// Remove host prefix if present
|
||||
if path.contains(sourceHost) {
|
||||
if let range = path.range(of: sourceHost) {
|
||||
let afterHost = path[range.upperBound...]
|
||||
path = String(afterHost)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove leading/trailing slashes for consistency
|
||||
path = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
|
||||
// Skip the root directory itself
|
||||
let normalizedBasePath = basePath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
if path == normalizedBasePath || path.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get display name
|
||||
let name: String
|
||||
if let displayName = response.displayName, !displayName.isEmpty {
|
||||
name = displayName
|
||||
} else {
|
||||
// Fall back to last path component
|
||||
name = (path as NSString).lastPathComponent
|
||||
}
|
||||
|
||||
// Skip hidden files
|
||||
if name.hasPrefix(".") {
|
||||
return nil
|
||||
}
|
||||
|
||||
return MediaFile(
|
||||
source: source,
|
||||
path: "/" + path,
|
||||
name: name,
|
||||
isDirectory: response.isCollection,
|
||||
size: response.contentLength,
|
||||
modifiedDate: response.lastModified,
|
||||
mimeType: response.contentType
|
||||
)
|
||||
}
|
||||
|
||||
private func parseHTTPDate(_ string: String) -> Date? {
|
||||
// HTTP dates can be in various formats
|
||||
let formatters: [DateFormatter] = [
|
||||
{
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
|
||||
return f
|
||||
}(),
|
||||
{
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.dateFormat = "EEEE, dd-MMM-yy HH:mm:ss zzz"
|
||||
return f
|
||||
}(),
|
||||
{
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.dateFormat = "EEE MMM d HH:mm:ss yyyy"
|
||||
return f
|
||||
}()
|
||||
]
|
||||
|
||||
for formatter in formatters {
|
||||
if let date = formatter.date(from: string) {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
// Try ISO 8601
|
||||
let isoFormatter = ISO8601DateFormatter()
|
||||
return isoFormatter.date(from: string)
|
||||
}
|
||||
}
|
||||
33
Yattee/Services/MediaSources/WebDAVClientFactory.swift
Normal file
33
Yattee/Services/MediaSources/WebDAVClientFactory.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// WebDAVClientFactory.swift
|
||||
// Yattee
|
||||
//
|
||||
// Factory for creating WebDAVClient instances with appropriate SSL settings.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Factory for creating WebDAVClient instances based on media source SSL settings.
|
||||
final class WebDAVClientFactory: Sendable {
|
||||
private let sessionFactory: URLSessionFactory
|
||||
|
||||
init(sessionFactory: URLSessionFactory = .shared) {
|
||||
self.sessionFactory = sessionFactory
|
||||
}
|
||||
|
||||
/// Creates a WebDAVClient configured for the given media source's SSL requirements.
|
||||
/// - Parameter source: The media source to create a client for.
|
||||
/// - Returns: A WebDAVClient with appropriate SSL settings.
|
||||
func createClient(for source: MediaSource) -> WebDAVClient {
|
||||
let session = sessionFactory.session(allowInvalidCertificates: source.allowInvalidCertificates)
|
||||
return WebDAVClient(session: session)
|
||||
}
|
||||
|
||||
/// Creates a WebDAVClient with explicit SSL settings.
|
||||
/// - Parameter allowInvalidCertificates: Whether to bypass SSL certificate validation.
|
||||
/// - Returns: A WebDAVClient with the specified SSL settings.
|
||||
func createClient(allowInvalidCertificates: Bool) -> WebDAVClient {
|
||||
let session = sessionFactory.session(allowInvalidCertificates: allowInvalidCertificates)
|
||||
return WebDAVClient(session: session)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user