mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
384
Yattee/Models/MediaSources/MediaFile.swift
Normal file
384
Yattee/Models/MediaSources/MediaFile.swift
Normal file
@@ -0,0 +1,384 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user