Files
yattee/Yattee/Models/MediaSources/MediaFile.swift
Arkadiusz Fal 4770bbe3b9 Remove dead code found by Periphery on all platforms
Delete declarations that Periphery reports unused on every platform that
compiles them (iOS, macOS, tvOS), including nine views used only by their
own previews. Platform-gated code still used on another platform, device-
only MPV render paths, migration models, CloudKit record names, App
Intents metadata and unused parameters are kept.
2026-10-08 23:57:56 +02:00

388 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?
// 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
}
// MARK: - Computed Properties
/// Full URL to this file.
var url: URL {
// For local folders, the persisted `source.url` may point at a stale app
// container path after iOS reinstall/restore. Prefer the freshly resolved
// bookmark URL stored in `LocalFolderURLResolver` when available.
if source.type == .localFolder,
let resolved = LocalFolderURLResolver.resolvedURL(for: source.id) {
return resolved.appendingPathComponent(path)
}
return 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
}
}