Files
yattee/Yattee/Models/MediaSources/MediaFile.swift
2026-02-08 18:33:56 +01:00

385 lines
13 KiB
Swift

//
// MediaFile.swift
// Yattee
//
// Represents a file or folder from a media source.
//
import Foundation
/// Represents a file or folder from a media source.
struct MediaFile: Identifiable, Hashable, Sendable {
/// Unique identifier combining source and path.
var id: String { "\(source.id):\(path)" }
/// The media source this file belongs to.
let source: MediaSource
/// Full path from the source root.
let path: String
/// Display name of the file/folder.
let name: String
/// Whether this is a directory.
let isDirectory: Bool
/// Whether this is an SMB share (top-level directory on SMB server).
let isShare: Bool
/// File size in bytes (nil for directories).
let size: Int64?
/// Last modified date.
let modifiedDate: Date?
/// Creation date.
let createdDate: Date?
/// MIME type if known.
let mimeType: String?
// MARK: - Initialization
init(
source: MediaSource,
path: String,
name: String,
isDirectory: Bool,
isShare: Bool = false,
size: Int64? = nil,
modifiedDate: Date? = nil,
createdDate: Date? = nil,
mimeType: String? = nil
) {
self.source = source
self.path = path
self.name = name
self.isDirectory = isDirectory
self.isShare = isShare
self.size = size
self.modifiedDate = modifiedDate
self.createdDate = createdDate
self.mimeType = mimeType
}
// MARK: - Computed Properties
/// Full URL to this file.
var url: URL {
source.url.appendingPathComponent(path)
}
/// File extension (lowercase).
var fileExtension: String {
(name as NSString).pathExtension.lowercased()
}
/// Whether this file is a video.
var isVideo: Bool {
guard !isDirectory else { return false }
return Self.videoExtensions.contains(fileExtension)
}
/// Whether this file is an audio file.
var isAudio: Bool {
guard !isDirectory else { return false }
return Self.audioExtensions.contains(fileExtension)
}
/// Whether this file is playable media.
var isPlayable: Bool {
isVideo || isAudio
}
/// Whether this file is a subtitle file.
var isSubtitle: Bool {
guard !isDirectory else { return false }
return Self.subtitleExtensions.contains(fileExtension)
}
/// File name without extension.
var baseName: String {
(name as NSString).deletingPathExtension
}
/// Formatted file size string.
var formattedSize: String? {
// Don't show size for directories and shares
guard !isDirectory, !isShare else { return nil }
guard let size else { return nil }
return ByteCountFormatter.string(fromByteCount: size, countStyle: .file)
}
/// System image name for the file type.
var systemImage: String {
if isShare {
return "externaldrive.fill"
}
if isDirectory {
return "folder.fill"
}
if isVideo {
return "film"
}
if isAudio {
return "music.note"
}
if isSubtitle {
return "captions.bubble"
}
return "doc"
}
// MARK: - Supported Extensions
static let videoExtensions: Set<String> = [
"mp4", "m4v", "mov", "mkv", "avi", "webm", "wmv",
"flv", "mpg", "mpeg", "3gp", "3gpp", "ts", "m2ts",
"vob", "ogv", "rm", "rmvb", "asf", "divx"
]
static let audioExtensions: Set<String> = [
"mp3", "m4a", "aac", "flac", "wav", "ogg", "opus",
"wma", "aiff", "alac", "ape"
]
static let subtitleExtensions: Set<String> = [
"srt", "vtt", "ass", "ssa", "sub"
]
}
// MARK: - Conversion to Video/Stream
extension MediaFile {
/// Provider name for WebDAV media sources (syncs to iCloud).
static let webdavProvider = "media_source_webdav"
/// Provider name for local folder media sources (device-specific, never syncs).
static let localFolderProvider = "media_source_local"
/// Provider name for SMB media sources (syncs to iCloud).
static let smbProvider = "media_source_smb"
/// Returns the appropriate provider name based on source type.
private var providerName: String {
switch source.type {
case .webdav:
return Self.webdavProvider
case .localFolder:
return Self.localFolderProvider
case .smb:
return Self.smbProvider
}
}
/// Creates a Video model for playback.
func toVideo() -> Video {
Video(
id: VideoID(
source: .extracted(extractor: providerName, originalURL: url),
videoID: id
),
title: (name as NSString).deletingPathExtension,
description: nil,
author: Author(id: source.id.uuidString, name: source.name),
duration: 0, // Unknown until playback
publishedAt: modifiedDate,
publishedText: modifiedDate?.formatted(date: .abbreviated, time: .shortened),
viewCount: nil,
likeCount: nil,
thumbnails: [], // No thumbnails for local/WebDAV files
isLive: false,
isUpcoming: false,
scheduledStartTime: nil
)
}
/// Creates a Stream for direct playback.
/// - Parameter authHeaders: Optional HTTP headers for authentication.
func toStream(authHeaders: [String: String]? = nil) -> Stream {
Stream(
url: url,
resolution: nil, // Unknown
format: fileExtension,
httpHeaders: authHeaders
)
}
}
// MARK: - Preview Support
extension MediaFile {
/// Sample media file for previews.
static var preview: MediaFile {
MediaFile(
source: .webdav(name: "My NAS", url: URL(string: "https://nas.local")!),
path: "/Movies/Sample Movie.mp4",
name: "Sample Movie.mp4",
isDirectory: false,
size: 1_500_000_000,
modifiedDate: Date().addingTimeInterval(-86400 * 7)
)
}
/// Sample folder for previews.
static var folderPreview: MediaFile {
MediaFile(
source: .webdav(name: "My NAS", url: URL(string: "https://nas.local")!),
path: "/Movies",
name: "Movies",
isDirectory: true
)
}
}
// MARK: - Subtitle Matching
extension MediaFile {
/// Finds subtitle files in the given list that match this video file.
/// - Parameter files: List of files to search for matching subtitles.
/// - Returns: Array of Caption objects for matching subtitle files.
func findMatchingSubtitles(in files: [MediaFile]) -> [Caption] {
guard isVideo else { return [] }
let videoBaseName = baseName
return files
.filter { $0.isSubtitle && $0.matchesVideo(baseName: videoBaseName) }
.compactMap { $0.toCaption(videoBaseName: videoBaseName) }
.sorted { caption1, caption2 in
// Sort: exact match (und) first, then alphabetically by language
if caption1.languageCode == "und" && caption2.languageCode != "und" {
return true
}
if caption2.languageCode == "und" && caption1.languageCode != "und" {
return false
}
return caption1.displayName.localizedCaseInsensitiveCompare(caption2.displayName) == .orderedAscending
}
}
/// Checks if this subtitle file matches a video with the given base name.
private func matchesVideo(baseName videoBaseName: String) -> Bool {
let subtitleBase = baseName
// Exact match: video.srt for video.mp4
if subtitleBase == videoBaseName {
return true
}
// Language suffix with dot: video.en.srt, video.eng.srt, video.English.srt
if subtitleBase.hasPrefix(videoBaseName + ".") {
return true
}
// Language suffix with underscore: video_en.srt
if subtitleBase.hasPrefix(videoBaseName + "_") {
return true
}
return false
}
/// Converts this subtitle file to a Caption object.
private func toCaption(videoBaseName: String) -> Caption? {
guard isSubtitle else { return nil }
let languageCode = extractLanguageCode(videoBaseName: videoBaseName)
let displayName: String
if languageCode == "und" {
// For exact match without language code, use "Default" as label
displayName = String(localized: "subtitles.default", defaultValue: "Default")
} else if let localizedName = Locale.current.localizedString(forLanguageCode: languageCode) {
displayName = localizedName
} else {
displayName = languageCode
}
return Caption(
label: displayName,
languageCode: languageCode,
url: url
)
}
/// Extracts the language code from this subtitle filename.
private func extractLanguageCode(videoBaseName: String) -> String {
let subtitleBase = baseName
// Check for pattern: video.en.srt or video_en.srt
let dotPrefix = videoBaseName + "."
let underscorePrefix = videoBaseName + "_"
var suffix: String?
if subtitleBase.hasPrefix(dotPrefix) {
suffix = String(subtitleBase.dropFirst(dotPrefix.count))
} else if subtitleBase.hasPrefix(underscorePrefix) {
suffix = String(subtitleBase.dropFirst(underscorePrefix.count))
}
guard let suffix, !suffix.isEmpty else {
return "und" // undefined - exact match without language
}
// Normalize the language code
return Self.normalizeLanguageCode(suffix)
}
/// Normalizes a language code or name to a standard 2-letter code.
private static func normalizeLanguageCode(_ input: String) -> String {
let lowercased = input.lowercased()
// Map common 3-letter codes to 2-letter
let threeToTwo: [String: String] = [
"eng": "en", "deu": "de", "ger": "de", "fra": "fr", "fre": "fr",
"spa": "es", "ita": "it", "por": "pt", "rus": "ru", "jpn": "ja",
"kor": "ko", "zho": "zh", "chi": "zh", "ara": "ar", "hin": "hi",
"pol": "pl", "nld": "nl", "dut": "nl", "swe": "sv", "nor": "no",
"dan": "da", "fin": "fi", "tur": "tr", "ces": "cs", "cze": "cs",
"hun": "hu", "ell": "el", "gre": "el", "heb": "he", "tha": "th",
"vie": "vi", "ukr": "uk", "ron": "ro", "rum": "ro", "bul": "bg",
"hrv": "hr", "slk": "sk", "slo": "sk", "slv": "sl", "srp": "sr",
"cat": "ca", "eus": "eu", "baq": "eu", "glg": "gl", "ind": "id",
"msa": "ms", "may": "ms", "fil": "tl", "tgl": "tl"
]
if let twoLetter = threeToTwo[lowercased] {
return twoLetter
}
// Map common full language names to codes
let nameToCode: [String: String] = [
"english": "en", "german": "de", "deutsch": "de", "french": "fr",
"français": "fr", "francais": "fr", "spanish": "es", "español": "es",
"espanol": "es", "italian": "it", "italiano": "it", "portuguese": "pt",
"português": "pt", "portugues": "pt", "russian": "ru", "русский": "ru",
"japanese": "ja", "日本語": "ja", "korean": "ko", "한국어": "ko",
"chinese": "zh", "中文": "zh", "arabic": "ar", "العربية": "ar",
"hindi": "hi", "हिन्दी": "hi", "polish": "pl", "polski": "pl",
"dutch": "nl", "nederlands": "nl", "swedish": "sv", "svenska": "sv",
"norwegian": "no", "norsk": "no", "danish": "da", "dansk": "da",
"finnish": "fi", "suomi": "fi", "turkish": "tr", "türkçe": "tr",
"czech": "cs", "čeština": "cs", "hungarian": "hu", "magyar": "hu",
"greek": "el", "ελληνικά": "el", "hebrew": "he", "עברית": "he",
"thai": "th", "ไทย": "th", "vietnamese": "vi", "tiếng việt": "vi",
"ukrainian": "uk", "українська": "uk", "romanian": "ro", "română": "ro",
"bulgarian": "bg", "български": "bg", "croatian": "hr", "hrvatski": "hr",
"slovak": "sk", "slovenčina": "sk", "slovenian": "sl", "slovenščina": "sl",
"serbian": "sr", "српски": "sr"
]
if let code = nameToCode[lowercased] {
return code
}
// If it's already a 2-letter code, return it
if input.count == 2 {
return lowercased
}
// Unknown - return as-is (could be a valid code we don't recognize)
return lowercased
}
}