mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
385 lines
13 KiB
Swift
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
|
|
}
|
|
}
|