280 lines
10 KiB
Swift
280 lines
10 KiB
Swift
import Foundation
|
|
|
|
@MainActor
|
|
class JellyfinService {
|
|
static let shared = JellyfinService()
|
|
private let configService = ConfigService.shared
|
|
|
|
private let baseUrl = "https://jellyfin.rockvilletollandsda.church"
|
|
private var apiKey: String? {
|
|
configService.jellyfinApiKey
|
|
}
|
|
private var libraryId: String?
|
|
private var currentType: MediaType = .sermons
|
|
|
|
enum MediaType: String {
|
|
case sermons = "Sermons"
|
|
case livestreams = "LiveStreams"
|
|
}
|
|
|
|
enum JellyfinError: Error {
|
|
case invalidURL
|
|
case networkError
|
|
case decodingError
|
|
case noVideosFound
|
|
case libraryNotFound
|
|
}
|
|
|
|
struct JellyfinItem: Codable {
|
|
let id: String
|
|
let name: String
|
|
let tags: [String]?
|
|
let premiereDate: String?
|
|
let productionYear: Int?
|
|
let overview: String?
|
|
let mediaType: String
|
|
let type: String
|
|
let path: String
|
|
let dateCreated: String
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id = "Id"
|
|
case name = "Name"
|
|
case tags = "Tags"
|
|
case premiereDate = "PremiereDate"
|
|
case productionYear = "ProductionYear"
|
|
case overview = "Overview"
|
|
case mediaType = "MediaType"
|
|
case type = "Type"
|
|
case path = "Path"
|
|
case dateCreated = "DateCreated"
|
|
}
|
|
}
|
|
|
|
private struct LibraryResponse: Codable {
|
|
let items: [Library]
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case items = "Items"
|
|
}
|
|
}
|
|
|
|
private struct Library: Codable {
|
|
let id: String
|
|
let name: String
|
|
let path: String
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id = "Id"
|
|
case name = "Name"
|
|
case path = "Path"
|
|
}
|
|
}
|
|
|
|
private struct ItemsResponse: Codable {
|
|
let items: [JellyfinItem]
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case items = "Items"
|
|
}
|
|
}
|
|
|
|
private init() {}
|
|
|
|
func setType(_ type: MediaType) {
|
|
self.currentType = type
|
|
self.libraryId = nil // Reset library ID when switching types
|
|
}
|
|
|
|
private func fetchWithAuth(_ url: URL) async throws -> (Data, URLResponse) {
|
|
// Ensure config is loaded
|
|
if configService.config == nil {
|
|
await configService.loadConfig()
|
|
}
|
|
|
|
guard let apiKey = self.apiKey else {
|
|
throw JellyfinError.networkError
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.timeoutInterval = 30
|
|
request.addValue(apiKey, forHTTPHeaderField: "X-MediaBrowser-Token")
|
|
request.addValue("MediaBrowser Client=\"RTSDA iOS\", Device=\"iOS\", DeviceId=\"rtsda-ios\", Version=\"1.0.0\", Token=\"\(apiKey)\"",
|
|
forHTTPHeaderField: "X-Emby-Authorization")
|
|
|
|
do {
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
|
|
// Check if task was cancelled
|
|
try Task.checkCancellation()
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw JellyfinError.networkError
|
|
}
|
|
|
|
guard (200...299).contains(httpResponse.statusCode) else {
|
|
if let responseString = String(data: data, encoding: .utf8) {
|
|
print("Jellyfin API error: \(responseString)")
|
|
}
|
|
throw JellyfinError.networkError
|
|
}
|
|
|
|
return (data, response)
|
|
} catch is CancellationError {
|
|
throw URLError(.cancelled)
|
|
} catch {
|
|
print("Network request failed: \(error.localizedDescription)")
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private func getLibraryId() async throws -> String {
|
|
if let id = libraryId { return id }
|
|
|
|
let url = URL(string: "\(baseUrl)/Library/MediaFolders")!
|
|
|
|
do {
|
|
let (data, _) = try await fetchWithAuth(url)
|
|
|
|
// Check if task was cancelled
|
|
try Task.checkCancellation()
|
|
|
|
let response = try JSONDecoder().decode(LibraryResponse.self, from: data)
|
|
|
|
let searchTerm = currentType == .sermons ? "Sermons" : "LiveStreams"
|
|
|
|
// Try exact match on name
|
|
if let library = response.items.first(where: { $0.name == searchTerm }) {
|
|
libraryId = library.id
|
|
return library.id
|
|
}
|
|
|
|
print("Library not found: \(searchTerm)")
|
|
print("Available libraries: \(response.items.map { $0.name }.joined(separator: ", "))")
|
|
throw JellyfinError.libraryNotFound
|
|
} catch is CancellationError {
|
|
throw URLError(.cancelled)
|
|
} catch {
|
|
print("Error fetching library ID: \(error.localizedDescription)")
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private func parseDate(_ dateString: String) -> Date? {
|
|
// Create formatters for different possible formats
|
|
let formatters = [
|
|
{ () -> DateFormatter in
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ" // PocketBase format
|
|
return formatter
|
|
}(),
|
|
{ () -> ISO8601DateFormatter in
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
return formatter
|
|
}(),
|
|
{ () -> ISO8601DateFormatter in
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime]
|
|
return formatter
|
|
}()
|
|
]
|
|
|
|
for formatter in formatters {
|
|
if let date = (formatter as? ISO8601DateFormatter)?.date(from: dateString) ??
|
|
(formatter as? DateFormatter)?.date(from: dateString) {
|
|
return date
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func fetchSermons(type: SermonType) async throws -> [Sermon] {
|
|
currentType = MediaType(rawValue: type.rawValue)!
|
|
let libraryId = try await getLibraryId()
|
|
|
|
// Check if task was cancelled
|
|
try Task.checkCancellation()
|
|
|
|
let urlString = "\(baseUrl)/Items?ParentId=\(libraryId)&Fields=Path,PremiereDate,ProductionYear,Overview,DateCreated&Recursive=true&IncludeItemTypes=Movie,Video,Episode&SortBy=DateCreated&SortOrder=Descending"
|
|
guard let url = URL(string: urlString) else {
|
|
throw JellyfinError.invalidURL
|
|
}
|
|
|
|
let (data, _) = try await fetchWithAuth(url)
|
|
|
|
// Check if task was cancelled
|
|
try Task.checkCancellation()
|
|
|
|
let response = try JSONDecoder().decode(ItemsResponse.self, from: data)
|
|
|
|
return response.items.map { item in
|
|
var title = item.name
|
|
var speaker = "Unknown Speaker"
|
|
|
|
// Remove file extension if present
|
|
title = title.replacingOccurrences(of: #"\.(mp4|mov)$"#, with: "", options: .regularExpression)
|
|
|
|
// Try to split into title and speaker
|
|
let parts = title.components(separatedBy: " - ")
|
|
if parts.count > 1 {
|
|
title = parts[0].trimmingCharacters(in: .whitespaces)
|
|
let speakerPart = parts[1].trimmingCharacters(in: .whitespaces)
|
|
speaker = speakerPart.replacingOccurrences(
|
|
of: #"\s+(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d+(?:th|st|nd|rd)?\s*\d{4}$"#,
|
|
with: "",
|
|
options: .regularExpression
|
|
).replacingOccurrences(of: "|", with: "").trimmingCharacters(in: .whitespaces)
|
|
}
|
|
|
|
// Parse date with UTC handling
|
|
let rawDate = item.premiereDate ?? item.dateCreated
|
|
let utcDate = parseDate(rawDate) ?? Date()
|
|
|
|
// Extract components in UTC and create a new date at noon UTC to avoid timezone issues
|
|
var calendar = Calendar.current
|
|
calendar.timeZone = TimeZone(identifier: "UTC")!
|
|
var components = calendar.dateComponents([.year, .month, .day], from: utcDate)
|
|
components.hour = 12 // Set to noon UTC to ensure date remains the same in all timezones
|
|
let localDate = calendar.date(from: components) ?? Date()
|
|
|
|
return Sermon(
|
|
id: item.id,
|
|
title: title,
|
|
description: item.overview ?? "",
|
|
date: localDate,
|
|
speaker: speaker,
|
|
type: type,
|
|
videoUrl: getStreamUrl(itemId: item.id),
|
|
thumbnail: getImageUrl(itemId: item.id)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func getStreamUrl(itemId: String) -> String {
|
|
var components = URLComponents(string: "\(baseUrl)/Videos/\(itemId)/master.m3u8")!
|
|
guard let apiKey = self.apiKey else { return "" }
|
|
components.queryItems = [
|
|
URLQueryItem(name: "api_key", value: apiKey),
|
|
URLQueryItem(name: "MediaSourceId", value: itemId),
|
|
URLQueryItem(name: "TranscodingProtocol", value: "hls"),
|
|
URLQueryItem(name: "RequireAvc", value: "true"),
|
|
URLQueryItem(name: "MaxStreamingBitrate", value: "20000000"),
|
|
URLQueryItem(name: "VideoBitrate", value: "10000000"),
|
|
URLQueryItem(name: "AudioBitrate", value: "192000"),
|
|
URLQueryItem(name: "AudioCodec", value: "aac"),
|
|
URLQueryItem(name: "VideoCodec", value: "h264"),
|
|
URLQueryItem(name: "MaxAudioChannels", value: "2"),
|
|
URLQueryItem(name: "StartTimeTicks", value: "0"),
|
|
URLQueryItem(name: "SubtitleMethod", value: "Embed"),
|
|
URLQueryItem(name: "TranscodeReasons", value: "VideoCodecNotSupported")
|
|
]
|
|
return components.url!.absoluteString
|
|
}
|
|
|
|
private func getImageUrl(itemId: String) -> String {
|
|
guard let apiKey = self.apiKey else { return "" }
|
|
return "\(baseUrl)/Items/\(itemId)/Images/Primary?api_key=\(apiKey)"
|
|
}
|
|
} |