Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,79 @@
//
// AvatarURLBuilder.swift
// Yattee
//
// Utility for constructing channel avatar URLs with Yattee Server fallback.
//
import Foundation
import Nuke
/// Utility for constructing channel avatar URLs with Yattee Server fallback
enum AvatarURLBuilder {
/// Available avatar sizes on Yattee Server
private static let availableSizes = [32, 48, 76, 100, 176, 512]
/// Constructs the effective avatar URL for a channel
/// - Parameters:
/// - channelID: The channel ID
/// - directURL: Direct avatar URL if available (from API response)
/// - serverURL: Yattee Server base URL
/// - size: Desired size in points (will be doubled for retina and rounded to nearest available)
/// - Returns: URL to use for avatar, or nil if unavailable
static func avatarURL(
channelID: String,
directURL: URL?,
serverURL: URL?,
size: Int
) -> URL? {
// Check if this is a YouTube channel (UC prefix or @handle)
let isYouTubeChannel = channelID.hasPrefix("UC") || channelID.hasPrefix("@")
// Priority 1: For YouTube channels, prefer Yattee Server (more reliable, avoids stale URLs from iCloud sync)
if isYouTubeChannel, let serverURL = serverURL {
return buildServerAvatarURL(serverURL: serverURL, channelID: channelID, size: size)
}
// Priority 2: Use direct URL for non-YouTube channels or when server unavailable
if let directURL = directURL {
return directURL
}
// Priority 3: Try server as last resort (for YouTube without direct URL)
if let serverURL = serverURL {
return buildServerAvatarURL(serverURL: serverURL, channelID: channelID, size: size)
}
return nil
}
/// Builds the Yattee Server avatar URL for a channel
private static func buildServerAvatarURL(serverURL: URL, channelID: String, size: Int) -> URL {
// Calculate retina size and round to nearest available
let retinaSize = size * 2
let roundedSize = availableSizes
.min { abs($0 - retinaSize) < abs($1 - retinaSize) } ?? 176
return serverURL
.appendingPathComponent("api/v1/channels")
.appendingPathComponent(channelID)
.appendingPathComponent("avatar")
.appendingPathComponent("\(roundedSize).jpg")
}
/// Creates an ImageRequest with auth header for Yattee Server avatar URLs
/// - Parameters:
/// - url: The avatar URL
/// - authHeader: Optional Basic Auth header for Yattee Server
/// - Returns: ImageRequest configured with auth if needed, or nil if URL is nil
static func imageRequest(url: URL?, authHeader: String?) -> ImageRequest? {
guard let url else { return nil }
var request = URLRequest(url: url)
// Only add auth header for Yattee Server avatar URLs
if let authHeader, url.path.contains("/api/v1/channels/") && url.path.contains("/avatar/") {
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
}
return ImageRequest(urlRequest: request)
}
}

View File

@@ -0,0 +1,307 @@
//
// ChapterParser.swift
// Yattee
//
// Parses video chapters from description text.
//
import Foundation
/// Parses video chapters from description text using timestamp detection.
struct ChapterParser: Sendable {
// MARK: - Constants
/// Characters that may prefix a timestamp (to be stripped).
private static let prefixCharacters = CharacterSet(charactersIn: "▶►•-*→➤")
/// Characters that may separate timestamp from title (to be stripped).
private static let separatorCharacters = CharacterSet(charactersIn: "-|:–—")
/// Regex pattern for strict timestamp matching.
/// Matches: M:SS, MM:SS, MMM:SS (for long videos), H:MM:SS, HH:MM:SS
/// Does not match timestamps in brackets/parentheses (handled by caller).
private static let timestampPattern = #"^(\d{1,3}):(\d{2})(?::(\d{2}))?$"#
// MARK: - Public API
/// Parses chapters from a video description.
///
/// - Parameters:
/// - description: The video description text (may be nil).
/// - videoDuration: The video duration in seconds. Must be > 0.
/// - introTitle: Title for synthetic intro chapter if first chapter doesn't start at 0:00.
/// - Returns: Array of chapters, or empty array if parsing fails or no valid chapters found.
static func parse(
description: String?,
videoDuration: TimeInterval,
introTitle: String = "Intro"
) -> [VideoChapter] {
// Validate inputs
guard let description, !description.isEmpty else { return [] }
guard videoDuration > 0 else { return [] }
// Extract raw chapters from description
let rawChapters = extractChapterBlock(from: description)
// Filter out chapters beyond video duration
let validChapters = rawChapters.filter { $0.startTime < videoDuration }
// Need minimum 2 chapters
guard validChapters.count >= 2 else { return [] }
// Sort chronologically
let sorted = validChapters.sorted { $0.startTime < $1.startTime }
// Merge duplicate timestamps
let merged = mergeDuplicateTimestamps(sorted)
// Insert synthetic intro if needed
let withIntro = insertSyntheticIntro(merged, introTitle: introTitle)
// Convert to VideoChapter with end times
return buildVideoChapters(from: withIntro, videoDuration: videoDuration)
}
// MARK: - Private Parsing Methods
/// Extracts the first contiguous block of chapter lines from the description.
private static func extractChapterBlock(from description: String) -> [(startTime: TimeInterval, title: String)] {
let lines = description.components(separatedBy: .newlines)
var chapters: [(startTime: TimeInterval, title: String)] = []
var inBlock = false
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
// Empty lines don't break the block
if trimmed.isEmpty {
continue
}
// Try to parse as chapter line
if let chapter = parseChapterLine(trimmed) {
inBlock = true
chapters.append(chapter)
} else if inBlock {
// Check if this looks like a timestamp line (even if invalid)
// Lines that look like they could be timestamps continue the block
// Lines that clearly aren't timestamps break the block
if looksLikeTimestampLine(trimmed) {
// Invalid timestamp format (brackets, no title, etc.) - skip but continue block
continue
} else {
// Clearly not a timestamp line - break the block
break
}
}
// If not in block yet and line isn't a chapter, keep looking
}
return chapters
}
/// Checks if a line looks like it could be a timestamp line.
/// Returns true for lines that start with timestamp-like patterns,
/// even if they would be rejected for other reasons (brackets, no title, etc.).
private static func looksLikeTimestampLine(_ line: String) -> Bool {
var working = line
// Strip brackets/parentheses to check what's inside
if working.hasPrefix("[") || working.hasPrefix("(") {
working = String(working.dropFirst())
}
// Strip prefix characters
working = stripPrefixes(working)
// Check if it starts with a digit (potential timestamp)
guard let first = working.first, first.isNumber else {
return false
}
// Look for timestamp pattern (digits, colons)
let timestampChars = CharacterSet(charactersIn: "0123456789:")
let prefix = working.prefix(while: { char in
char.unicodeScalars.allSatisfy { timestampChars.contains($0) }
})
// Must have at least one colon to look like a timestamp
return prefix.contains(":")
}
/// Parses a single line as a chapter entry.
///
/// - Parameter line: A trimmed line from the description.
/// - Returns: A tuple of (startTime, title) if valid, nil otherwise.
private static func parseChapterLine(_ line: String) -> (startTime: TimeInterval, title: String)? {
var working = line
// Skip if wrapped in brackets or parentheses
if working.hasPrefix("[") || working.hasPrefix("(") {
return nil
}
// Strip prefix characters
working = stripPrefixes(working)
// Find the timestamp at the start
guard let (timestamp, remainingAfterTimestamp) = extractLeadingTimestamp(from: working) else {
return nil
}
// Strip separators from the title
let title = stripSeparators(remainingAfterTimestamp).trimmingCharacters(in: .whitespaces)
// Skip if no title
guard !title.isEmpty else { return nil }
return (timestamp, title)
}
/// Strips known prefix characters from the start of a string.
private static func stripPrefixes(_ string: String) -> String {
var result = string
while let first = result.unicodeScalars.first,
prefixCharacters.contains(first) {
result = String(result.dropFirst())
result = result.trimmingCharacters(in: .whitespaces)
}
return result
}
/// Strips known separator characters from the start of a string.
private static func stripSeparators(_ string: String) -> String {
var result = string.trimmingCharacters(in: .whitespaces)
while let first = result.unicodeScalars.first,
separatorCharacters.contains(first) {
result = String(result.dropFirst())
result = result.trimmingCharacters(in: .whitespaces)
}
return result
}
/// Extracts a timestamp from the start of a string.
///
/// - Parameter string: The string to parse.
/// - Returns: A tuple of (timestamp in seconds, remaining string after timestamp) if found.
private static func extractLeadingTimestamp(from string: String) -> (TimeInterval, String)? {
// Find where the timestamp ends (first space or separator after digits/colons)
let timestampEndIndex = string.firstIndex { char in
char == " " || char == "\t" || char == "-" || char == "|" || char == "" || char == ""
} ?? string.endIndex
let potentialTimestamp = String(string[..<timestampEndIndex])
let remaining = String(string[timestampEndIndex...])
guard let seconds = parseTimestamp(potentialTimestamp) else {
return nil
}
return (seconds, remaining)
}
/// Parses a timestamp string into seconds.
///
/// Supported formats: M:SS, MM:SS, H:MM:SS, HH:MM:SS
///
/// - Parameter timestamp: The timestamp string (e.g., "1:23:45" or "5:30").
/// - Returns: The time in seconds, or nil if invalid format.
private static func parseTimestamp(_ timestamp: String) -> TimeInterval? {
guard let regex = try? NSRegularExpression(pattern: timestampPattern) else {
return nil
}
let range = NSRange(timestamp.startIndex..., in: timestamp)
guard let match = regex.firstMatch(in: timestamp, range: range) else {
return nil
}
// Extract capture groups
guard let firstRange = Range(match.range(at: 1), in: timestamp),
let secondRange = Range(match.range(at: 2), in: timestamp) else {
return nil
}
let first = Int(timestamp[firstRange]) ?? 0
let second = Int(timestamp[secondRange]) ?? 0
// Check if third group (seconds in H:MM:SS format) exists
if match.range(at: 3).location != NSNotFound,
let thirdRange = Range(match.range(at: 3), in: timestamp) {
// H:MM:SS or HH:MM:SS format
let hours = first
let minutes = second
let seconds = Int(timestamp[thirdRange]) ?? 0
// Validate ranges
guard minutes < 60, seconds < 60 else { return nil }
return TimeInterval(hours * 3600 + minutes * 60 + seconds)
} else {
// M:SS or MM:SS format
let minutes = first
let seconds = second
// Validate seconds range
guard seconds < 60 else { return nil }
return TimeInterval(minutes * 60 + seconds)
}
}
// MARK: - Post-Processing Methods
/// Merges chapters with duplicate timestamps by combining their titles.
private static func mergeDuplicateTimestamps(
_ chapters: [(startTime: TimeInterval, title: String)]
) -> [(startTime: TimeInterval, title: String)] {
var result: [(startTime: TimeInterval, title: String)] = []
for chapter in chapters {
if let lastIndex = result.lastIndex(where: { $0.startTime == chapter.startTime }) {
// Merge with existing chapter at same timestamp
result[lastIndex].title += " / " + chapter.title
} else {
result.append(chapter)
}
}
return result
}
/// Inserts a synthetic intro chapter at 0:00 if the first chapter doesn't start there.
private static func insertSyntheticIntro(
_ chapters: [(startTime: TimeInterval, title: String)],
introTitle: String
) -> [(startTime: TimeInterval, title: String)] {
guard let first = chapters.first, first.startTime > 0 else {
return chapters
}
var result = chapters
result.insert((startTime: 0, title: introTitle), at: 0)
return result
}
/// Converts raw chapter data to VideoChapter objects with end times.
private static func buildVideoChapters(
from chapters: [(startTime: TimeInterval, title: String)],
videoDuration: TimeInterval
) -> [VideoChapter] {
return chapters.enumerated().map { index, chapter in
let endTime: TimeInterval
if index < chapters.count - 1 {
endTime = chapters[index + 1].startTime
} else {
endTime = videoDuration
}
return VideoChapter(
title: chapter.title,
startTime: chapter.startTime,
endTime: endTime
)
}
}
}

View File

@@ -0,0 +1,30 @@
//
// CountFormatter.swift
// Yattee
//
// Centralized utility for formatting counts with compact notation (1K, 2.5K, etc.)
//
import Foundation
/// Utility for formatting counts with compact notation and proper pluralization.
enum CountFormatter {
/// Formats a count to compact notation (e.g., 1K, 2.5K, 1M, 1B).
/// - Parameter count: The number to format
/// - Returns: Formatted string (e.g., "150", "1.2K", "2.5M", "1B")
static func compact(_ count: Int) -> String {
switch count {
case 0..<1_000:
return "\(count)"
case 1_000..<1_000_000:
let value = Double(count) / 1_000
return String(format: "%.1fK", value).replacingOccurrences(of: ".0K", with: "K")
case 1_000_000..<1_000_000_000:
let value = Double(count) / 1_000_000
return String(format: "%.1fM", value).replacingOccurrences(of: ".0M", with: "M")
default:
let value = Double(count) / 1_000_000_000
return String(format: "%.1fB", value).replacingOccurrences(of: ".0B", with: "B")
}
}
}

View File

@@ -0,0 +1,103 @@
//
// DescriptionText.swift
// Yattee
//
// Utilities for parsing and formatting video description text with clickable links and timestamps.
//
import Foundation
import SwiftUI
// MARK: - Description Text Utilities
enum DescriptionText {
/// Creates an attributed string with clickable URLs and timestamps.
/// Timestamps are converted to `yattee-seek://SECONDS` URLs.
static func attributed(_ text: String, linkColor: Color = .accentColor) -> AttributedString {
var attributedString = AttributedString(text)
// URL regex pattern
let urlPattern = #"https?://[^\s<>\"\']+"#
if let regex = try? NSRegularExpression(pattern: urlPattern, options: []) {
let nsRange = NSRange(text.startIndex..., in: text)
let matches = regex.matches(in: text, options: [], range: nsRange)
for match in matches {
guard let range = Range(match.range, in: text),
let attributedRange = Range(range, in: attributedString),
let url = URL(string: String(text[range])) else {
continue
}
attributedString[attributedRange].link = url
attributedString[attributedRange].foregroundColor = linkColor
}
}
// Timestamp pattern: matches formats like 0:00, 00:00, 0:00:00, 00:00:00
// Must be at word boundary (not part of a larger number sequence)
let timestampPattern = #"(?<![:\d])(\d{1,2}:\d{2}(?::\d{2})?)(?![:\d])"#
if let timestampRegex = try? NSRegularExpression(pattern: timestampPattern, options: []) {
let nsRange = NSRange(text.startIndex..., in: text)
let matches = timestampRegex.matches(in: text, options: [], range: nsRange)
for match in matches {
guard let range = Range(match.range, in: text),
let attributedRange = Range(range, in: attributedString) else {
continue
}
let timestampString = String(text[range])
let seconds = parseTimestamp(timestampString)
if let url = URL(string: "yattee-seek://\(seconds)") {
attributedString[attributedRange].link = url
attributedString[attributedRange].foregroundColor = linkColor
}
}
}
return attributedString
}
/// Parses a timestamp string (MM:SS or H:MM:SS) into total seconds.
static func parseTimestamp(_ timestamp: String) -> Int {
let components = timestamp.split(separator: ":").compactMap { Int($0) }
switch components.count {
case 2: // MM:SS
return components[0] * 60 + components[1]
case 3: // H:MM:SS
return components[0] * 3600 + components[1] * 60 + components[2]
default:
return 0
}
}
/// URL scheme used for seek timestamps.
static let seekScheme = "yattee-seek"
/// Extracts the seconds value from a seek URL, if valid.
static func seekSeconds(from url: URL) -> Int? {
guard url.scheme == seekScheme else { return nil }
return Int(url.host ?? "")
}
}
// MARK: - OpenURL Action for Seeking
extension View {
/// Adds a URL handler that intercepts timestamp links and seeks the player.
func handleTimestampLinks(using playerService: PlayerService?) -> some View {
self.environment(\.openURL, OpenURLAction { url in
if let seconds = DescriptionText.seekSeconds(from: url) {
Task {
await playerService?.seek(to: TimeInterval(seconds))
}
return .handled
}
return .systemAction
})
}
}

View File

@@ -0,0 +1,158 @@
//
// DirectMediaHelper.swift
// Yattee
//
// Helper for creating Video/Stream objects from direct media URLs.
//
import Foundation
/// Helper for handling direct media URLs (mp4, m3u8, etc.) without extraction.
enum DirectMediaHelper {
/// Provider name for direct media URLs.
static let provider = "direct_media"
// MARK: - Supported Extensions
/// Video file extensions that can be played directly.
static let videoExtensions: Set<String> = [
"mp4", "m4v", "mov", "mkv", "avi", "webm", "wmv",
"flv", "mpg", "mpeg", "3gp", "ts", "m2ts"
]
/// Audio file extensions that can be played directly.
static let audioExtensions: Set<String> = [
"mp3", "m4a", "aac", "flac", "wav", "ogg", "opus"
]
/// Streaming format extensions (HLS, DASH).
static let streamingExtensions: Set<String> = [
"m3u8", "mpd"
]
/// All supported media extensions.
static var allExtensions: Set<String> {
videoExtensions.union(audioExtensions).union(streamingExtensions)
}
// MARK: - Detection
/// Checks if the URL points to a direct media file based on extension.
static func isDirectMediaURL(_ url: URL) -> Bool {
let pathExtension = url.pathExtension.lowercased()
return allExtensions.contains(pathExtension)
}
/// Checks if the URL is a streaming format (HLS/DASH).
static func isStreamingURL(_ url: URL) -> Bool {
let pathExtension = url.pathExtension.lowercased()
return streamingExtensions.contains(pathExtension)
}
/// Checks if the URL is an audio-only file.
static func isAudioURL(_ url: URL) -> Bool {
let pathExtension = url.pathExtension.lowercased()
return audioExtensions.contains(pathExtension)
}
// MARK: - Video/Stream Creation
/// Creates a Video model from a direct media URL.
static func createVideo(from url: URL) -> Video {
let filename = url.lastPathComponent
let title = (filename as NSString).deletingPathExtension
let host = url.host ?? "Direct Media"
return Video(
id: VideoID(
source: .extracted(extractor: provider, originalURL: url),
videoID: url.absoluteString
),
title: title.isEmpty ? filename : title,
description: nil,
author: Author(id: provider, name: host, hasRealChannelInfo: false),
duration: 0, // Unknown until playback
publishedAt: nil,
publishedText: nil,
viewCount: nil,
likeCount: nil,
thumbnails: [],
isLive: isStreamingURL(url), // Treat HLS/DASH as potentially live
isUpcoming: false,
scheduledStartTime: nil
)
}
/// Creates a Stream from a direct media URL.
static func createStream(from url: URL) -> Stream {
let pathExtension = url.pathExtension.lowercased()
let isAudio = isAudioURL(url)
let isStreaming = isStreamingURL(url)
return Stream(
url: url,
resolution: isAudio ? nil : .p720, // Default assumption for video
format: pathExtension,
videoCodec: isAudio ? nil : "unknown",
audioCodec: "unknown",
bitrate: nil,
fileSize: nil,
isAudioOnly: isAudio,
isLive: isStreaming,
mimeType: mimeType(for: pathExtension),
audioLanguage: nil,
audioTrackName: nil,
isOriginalAudio: true,
httpHeaders: nil,
fps: nil
)
}
// MARK: - MIME Type Mapping
/// Returns the MIME type for a file extension.
private static func mimeType(for extension: String) -> String? {
switch `extension` {
case "mp4", "m4v":
return "video/mp4"
case "mov":
return "video/quicktime"
case "mkv":
return "video/x-matroska"
case "avi":
return "video/x-msvideo"
case "webm":
return "video/webm"
case "wmv":
return "video/x-ms-wmv"
case "flv":
return "video/x-flv"
case "mpg", "mpeg":
return "video/mpeg"
case "3gp":
return "video/3gpp"
case "ts", "m2ts":
return "video/mp2t"
case "m3u8":
return "application/vnd.apple.mpegurl"
case "mpd":
return "application/dash+xml"
case "mp3":
return "audio/mpeg"
case "m4a":
return "audio/mp4"
case "aac":
return "audio/aac"
case "flac":
return "audio/flac"
case "wav":
return "audio/wav"
case "ogg":
return "audio/ogg"
case "opus":
return "audio/opus"
default:
return nil
}
}
}

View File

@@ -0,0 +1,33 @@
//
// RelativeDateFormatter.swift
// Yattee
//
// Centralized utility for formatting relative dates with "just now" support.
//
import Foundation
/// Utility for formatting dates relative to now, with proper handling of very recent times.
enum RelativeDateFormatter {
/// Formats a date relative to now, showing "just now" for very recent dates.
/// - Parameters:
/// - date: The date to format
/// - justNowThreshold: Seconds within which to show "just now" (default: 10)
/// - unitsStyle: The style for the relative date formatter (default: .abbreviated)
/// - Returns: Formatted string (e.g., "just now", "5 min ago", "2 hours ago")
static func string(
for date: Date,
justNowThreshold: TimeInterval = 10,
unitsStyle: RelativeDateTimeFormatter.UnitsStyle = .abbreviated
) -> String {
let interval = Date().timeIntervalSince(date)
if interval < justNowThreshold {
return String(localized: "common.justNow")
}
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = unitsStyle
return formatter.localizedString(for: date, relativeTo: Date())
}
}