feat: 优先刮削中文影视信息与语言资源
This commit is contained in:
@@ -446,7 +446,16 @@ final class AppModel: ObservableObject {
|
||||
let nfo = first(named: ["movie.nfo", "tvshow.nfo"], extensions: ["nfo"]) ?? (nfoFiles.count == 1 ? nfoFiles.first : nil)
|
||||
let poster = first(named: ["poster.jpg", "poster.jpeg", "poster.png", "folder.jpg", "folder.png"], extensions: ["jpg", "jpeg", "png", "webp"])
|
||||
let backdrop = first(named: ["fanart.jpg", "fanart.png", "backdrop.jpg", "background.jpg"], extensions: ["jpg", "jpeg", "png", "webp"])
|
||||
if nfo != nil || poster != nil || backdrop != nil { sidecars[video.id] = MediaSidecarFiles(nfo: nfo, poster: poster, backdrop: backdrop) }
|
||||
let subtitleExtensions: Set<String> = ["srt", "ass", "ssa", "sub", "vtt", "sup"]
|
||||
let subtitles = ordinaryFiles.filter { file in
|
||||
let lower = file.name.lowercased()
|
||||
guard subtitleExtensions.contains((lower as NSString).pathExtension) else { return false }
|
||||
let subtitleStem = (lower as NSString).deletingPathExtension
|
||||
return subtitleStem == stem || subtitleStem.hasPrefix(stem + ".") || subtitleStem.hasPrefix(stem + "-") || subtitleStem.hasPrefix(stem + "_")
|
||||
}
|
||||
if nfo != nil || poster != nil || backdrop != nil || !subtitles.isEmpty {
|
||||
sidecars[video.id] = MediaSidecarFiles(nfo: nfo, poster: poster, backdrop: backdrop, subtitles: subtitles)
|
||||
}
|
||||
}
|
||||
if recursive { queue += children.filter(\.isDirectory).map { ($0.id, current.path + [$0.name]) } }
|
||||
}
|
||||
@@ -468,7 +477,8 @@ final class AppModel: ObservableObject {
|
||||
func addNext() {
|
||||
guard let request = iterator.next() else { return }
|
||||
group.addTask {
|
||||
let fallback = MediaLibraryItem(file: request.file, tmdbID: nil, title: request.parsed.title, originalTitle: request.file.name, mediaKind: nil, releaseDate: request.parsed.year.map(String.init) ?? "", overview: "", posterData: nil, backdropData: nil)
|
||||
let language = Self.mediaLanguageProfile(fileName: request.file.name, subtitles: sidecars[request.file.id]?.subtitles ?? [])
|
||||
let fallback = MediaLibraryItem(file: request.file, tmdbID: nil, title: request.parsed.title, originalTitle: request.file.name, mediaKind: nil, releaseDate: request.parsed.year.map(String.init) ?? "", overview: "", posterData: nil, backdropData: nil, hasChineseAudio: language.audio, hasChineseSubtitle: language.subtitle)
|
||||
do {
|
||||
if let existing = await Self.existingScrape(for: request.file, fallback: fallback, sidecars: sidecars[request.file.id], fallbackKind: kind, api: api) {
|
||||
return (request.index, existing)
|
||||
@@ -484,19 +494,26 @@ final class AppModel: ObservableObject {
|
||||
guard let candidate = (response["results"]?.arrayValue ?? []).compactMap({ TMDBCandidate(json: $0, forcedKind: preferredKind == .automatic ? nil : preferredKind) }).first else {
|
||||
return (request.index, fallback)
|
||||
}
|
||||
let details = try? await api.tmdbDetails(id: candidate.id, mediaKind: candidate.mediaType, apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort)
|
||||
let localizedTitle = details?.firstString(["title", "name"]) ?? candidate.title
|
||||
let originalTitle = details?.firstString(["original_title", "original_name"]) ?? candidate.originalTitle
|
||||
let localizedOverview = details?.firstString(["overview"]) ?? candidate.overview
|
||||
let localizedReleaseDate = details?.firstString(["release_date", "first_air_date"]) ?? candidate.releaseDate
|
||||
let localizedPosterPath = details.flatMap { Self.preferredArtworkPath(in: $0, collection: "posters") } ?? details?.firstString(["poster_path"]) ?? candidate.posterPath
|
||||
let localizedBackdropPath = details.flatMap { Self.preferredArtworkPath(in: $0, collection: "backdrops") } ?? details?.firstString(["backdrop_path"]) ?? candidate.backdropPath
|
||||
let posterData: Data?
|
||||
if let path = candidate.posterPath {
|
||||
if let path = localizedPosterPath {
|
||||
posterData = try? await api.tmdbImage(path: path, size: "w500", apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort)
|
||||
} else {
|
||||
posterData = nil
|
||||
}
|
||||
let backdropData: Data?
|
||||
if request.index < 8, let path = candidate.backdropPath {
|
||||
if request.index < 8, let path = localizedBackdropPath {
|
||||
backdropData = try? await api.tmdbImage(path: path, size: "w1280", apiKey: apiKey, proxyHost: proxyHost, proxyPort: proxyPort)
|
||||
} else {
|
||||
backdropData = nil
|
||||
}
|
||||
return (request.index, MediaLibraryItem(file: request.file, tmdbID: candidate.id, title: candidate.title, originalTitle: request.file.name, mediaKind: candidate.mediaType, releaseDate: candidate.releaseDate, overview: candidate.overview, posterData: posterData, backdropData: backdropData))
|
||||
return (request.index, MediaLibraryItem(file: request.file, tmdbID: candidate.id, title: localizedTitle, originalTitle: originalTitle, mediaKind: candidate.mediaType, releaseDate: localizedReleaseDate, overview: localizedOverview, posterData: posterData, backdropData: backdropData, hasChineseAudio: language.audio, hasChineseSubtitle: language.subtitle))
|
||||
} catch {
|
||||
return (request.index, fallback)
|
||||
}
|
||||
@@ -522,7 +539,7 @@ final class AppModel: ObservableObject {
|
||||
let (loadedNFO, loadedPoster, loadedBackdrop) = await (nfoData, posterData, backdropData)
|
||||
guard let parsed = loadedNFO.flatMap(MediaNFOParser.parse) else {
|
||||
guard loadedPoster != nil || loadedBackdrop != nil else { return nil }
|
||||
return MediaLibraryItem(file: file, tmdbID: fallback.tmdbID, title: fallback.title, originalTitle: fallback.originalTitle, mediaKind: fallback.mediaKind, releaseDate: fallback.releaseDate, overview: fallback.overview, posterData: loadedPoster, backdropData: loadedBackdrop)
|
||||
return MediaLibraryItem(file: file, tmdbID: fallback.tmdbID, title: fallback.title, originalTitle: fallback.originalTitle, mediaKind: fallback.mediaKind, releaseDate: fallback.releaseDate, overview: fallback.overview, posterData: loadedPoster, backdropData: loadedBackdrop, hasChineseAudio: fallback.hasChineseAudio, hasChineseSubtitle: fallback.hasChineseSubtitle)
|
||||
}
|
||||
let inferredKind: TMDBMediaKind?
|
||||
switch fallbackKind {
|
||||
@@ -530,7 +547,31 @@ final class AppModel: ObservableObject {
|
||||
case .series: inferredKind = .tv
|
||||
case .mixed: inferredKind = parsed.mediaKind
|
||||
}
|
||||
return MediaLibraryItem(file: file, tmdbID: parsed.tmdbID, title: parsed.title, originalTitle: parsed.originalTitle, mediaKind: parsed.mediaKind ?? inferredKind, releaseDate: parsed.releaseDate, overview: parsed.overview, posterData: loadedPoster, backdropData: loadedBackdrop)
|
||||
return MediaLibraryItem(file: file, tmdbID: parsed.tmdbID, title: parsed.title, originalTitle: parsed.originalTitle, mediaKind: parsed.mediaKind ?? inferredKind, releaseDate: parsed.releaseDate, overview: parsed.overview, posterData: loadedPoster, backdropData: loadedBackdrop, hasChineseAudio: parsed.hasChineseAudio || fallback.hasChineseAudio, hasChineseSubtitle: parsed.hasChineseSubtitle || fallback.hasChineseSubtitle)
|
||||
}
|
||||
|
||||
private nonisolated static func mediaLanguageProfile(fileName: String, subtitles: [CloudFile]) -> (audio: Bool, subtitle: Bool) {
|
||||
let lower = fileName.lowercased()
|
||||
let audioMarkers = ["国语", "普通话", "国粤", "粤语", "mandarin", "cantonese", "chinese.audio", "chi.audio", "zh.audio"]
|
||||
let subtitleMarkers = ["中文", "中字", "简中", "繁中", "双语", ".chs", ".cht", ".chi", ".zho", ".zh", "chinese", "bilingual"]
|
||||
let hasAudio = audioMarkers.contains { lower.contains($0) }
|
||||
let hasSubtitle = subtitleMarkers.contains { lower.contains($0) } || subtitles.contains { subtitle in
|
||||
let name = subtitle.name.lowercased()
|
||||
return subtitleMarkers.contains { name.contains($0) }
|
||||
}
|
||||
return (hasAudio, hasSubtitle)
|
||||
}
|
||||
|
||||
private nonisolated static func preferredArtworkPath(in details: JSONValue, collection: String) -> String? {
|
||||
let images = details["images"]?[collection]?.arrayValue ?? []
|
||||
let languageOrder: [String?] = ["zh", "zh-CN", nil, "en"]
|
||||
for language in languageOrder {
|
||||
if let path = images.first(where: { image in
|
||||
let code = image.firstString(["iso_639_1"])
|
||||
return language == nil ? code == nil : code?.caseInsensitiveCompare(language!) == .orderedSame
|
||||
})?.firstString(["file_path"]) { return path }
|
||||
}
|
||||
return images.first?.firstString(["file_path"])
|
||||
}
|
||||
|
||||
private nonisolated static func sidecarData(for file: CloudFile, api: GuangyaAPI) async -> Data? {
|
||||
|
||||
@@ -268,7 +268,7 @@ final class GuangyaAPI: @unchecked Sendable {
|
||||
func tmdbSearch(query: String, apiKey: String, mediaKind: TMDBMediaKind = .automatic, proxyHost: String = "", proxyPort: String = "") async throws -> JSONValue {
|
||||
let endpoint = mediaKind == .movie ? "movie" : (mediaKind == .tv ? "tv" : "multi")
|
||||
var components = URLComponents(string: "https://api.themoviedb.org/3/search/\(endpoint)")!
|
||||
components.queryItems = [URLQueryItem(name: "api_key", value: apiKey), URLQueryItem(name: "query", value: query), URLQueryItem(name: "language", value: "zh-CN"), URLQueryItem(name: "include_adult", value: "false")]
|
||||
components.queryItems = [URLQueryItem(name: "api_key", value: apiKey), URLQueryItem(name: "query", value: query), URLQueryItem(name: "language", value: "zh-CN"), URLQueryItem(name: "region", value: "CN"), URLQueryItem(name: "include_adult", value: "false")]
|
||||
var request = URLRequest(url: components.url!)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
@@ -304,13 +304,13 @@ final class GuangyaAPI: @unchecked Sendable {
|
||||
func tmdbDetails(id: Int, mediaKind: TMDBMediaKind, apiKey: String, proxyHost: String = "", proxyPort: String = "") async throws -> JSONValue {
|
||||
let endpoint = mediaKind == .tv ? "tv" : "movie"
|
||||
var components = URLComponents(string: "https://api.themoviedb.org/3/\(endpoint)/\(id)")!
|
||||
components.queryItems = [URLQueryItem(name: "api_key", value: apiKey), URLQueryItem(name: "language", value: "zh-CN"), URLQueryItem(name: "append_to_response", value: "credits,images,external_ids")]
|
||||
components.queryItems = [URLQueryItem(name: "api_key", value: apiKey), URLQueryItem(name: "language", value: "zh-CN"), URLQueryItem(name: "append_to_response", value: "credits,images,external_ids,translations"), URLQueryItem(name: "include_image_language", value: "zh-CN,zh,null,en")]
|
||||
return try await tmdbJSONRequest(components.url!, proxyHost: proxyHost, proxyPort: proxyPort)
|
||||
}
|
||||
|
||||
func tmdbEpisodeDetails(seriesID: Int, season: Int, episode: Int, apiKey: String, proxyHost: String = "", proxyPort: String = "") async throws -> JSONValue {
|
||||
var components = URLComponents(string: "https://api.themoviedb.org/3/tv/\(seriesID)/season/\(season)/episode/\(episode)")!
|
||||
components.queryItems = [URLQueryItem(name: "api_key", value: apiKey), URLQueryItem(name: "language", value: "zh-CN"), URLQueryItem(name: "append_to_response", value: "credits,images")]
|
||||
components.queryItems = [URLQueryItem(name: "api_key", value: apiKey), URLQueryItem(name: "language", value: "zh-CN"), URLQueryItem(name: "append_to_response", value: "credits,images,translations"), URLQueryItem(name: "include_image_language", value: "zh-CN,zh,null,en")]
|
||||
return try await tmdbJSONRequest(components.url!, proxyHost: proxyHost, proxyPort: proxyPort)
|
||||
}
|
||||
|
||||
|
||||
@@ -45,11 +45,15 @@ actor MediaLibraryStore {
|
||||
overview TEXT NOT NULL,
|
||||
poster BLOB,
|
||||
backdrop BLOB,
|
||||
has_chinese_audio INTEGER NOT NULL DEFAULT 0,
|
||||
has_chinese_subtitle INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at REAL NOT NULL,
|
||||
PRIMARY KEY (library_id, file_id),
|
||||
FOREIGN KEY (library_id) REFERENCES media_libraries(id) ON DELETE CASCADE
|
||||
);
|
||||
""")
|
||||
Self.execute(database, "ALTER TABLE media_items ADD COLUMN has_chinese_audio INTEGER NOT NULL DEFAULT 0;")
|
||||
Self.execute(database, "ALTER TABLE media_items ADD COLUMN has_chinese_subtitle INTEGER NOT NULL DEFAULT 0;")
|
||||
Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_media_items_library_kind ON media_items(library_id, media_kind);")
|
||||
Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_media_items_gcid ON media_items(gcid);")
|
||||
}
|
||||
@@ -105,7 +109,8 @@ actor MediaLibraryStore {
|
||||
var statement: OpaquePointer?
|
||||
let sql = """
|
||||
SELECT file_id, resource_path, cloud_name, file_size, gcid, file_type, tmdb_id,
|
||||
media_kind, title, original_title, release_date, overview, poster, backdrop
|
||||
media_kind, title, original_title, release_date, overview, poster, backdrop,
|
||||
has_chinese_audio, has_chinese_subtitle
|
||||
FROM media_items WHERE library_id = ?1 ORDER BY title COLLATE NOCASE;
|
||||
"""
|
||||
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return [] }
|
||||
@@ -124,7 +129,7 @@ actor MediaLibraryStore {
|
||||
if let gcid = text(statement, 4) { json["gcid"] = .string(gcid) }
|
||||
guard let file = CloudFile(json: .object(json)) else { continue }
|
||||
let kind = text(statement, 7).flatMap(TMDBMediaKind.init(rawValue:))
|
||||
values.append(MediaLibraryItem(file: file, tmdbID: integer(statement, 6).map(Int.init), title: title, originalTitle: originalTitle, mediaKind: kind, releaseDate: releaseDate, overview: overview, posterData: blob(statement, 12), backdropData: blob(statement, 13)))
|
||||
values.append(MediaLibraryItem(file: file, tmdbID: integer(statement, 6).map(Int.init), title: title, originalTitle: originalTitle, mediaKind: kind, releaseDate: releaseDate, overview: overview, posterData: blob(statement, 12), backdropData: blob(statement, 13), hasChineseAudio: sqlite3_column_int(statement, 14) != 0, hasChineseSubtitle: sqlite3_column_int(statement, 15) != 0))
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -139,8 +144,9 @@ actor MediaLibraryStore {
|
||||
var statement: OpaquePointer?
|
||||
let sql = """
|
||||
INSERT INTO media_items (library_id, file_id, resource_path, cloud_name, file_size, gcid,
|
||||
file_type, tmdb_id, media_kind, title, original_title, release_date, overview, poster, backdrop, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16);
|
||||
file_type, tmdb_id, media_kind, title, original_title, release_date, overview, poster, backdrop,
|
||||
has_chinese_audio, has_chinese_subtitle, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18);
|
||||
"""
|
||||
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { Self.execute(database, "ROLLBACK;"); return }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
@@ -151,7 +157,8 @@ actor MediaLibraryStore {
|
||||
sqlite3_bind_int64(statement, 7, Int64(item.file.fileType)); bind(item.tmdbID.map(Int64.init), statement, 8)
|
||||
bind(item.mediaKind?.rawValue, statement, 9); bind(item.title, statement, 10); bind(item.originalTitle, statement, 11)
|
||||
bind(item.releaseDate, statement, 12); bind(item.overview, statement, 13); bind(item.posterData, statement, 14); bind(item.backdropData, statement, 15)
|
||||
sqlite3_bind_double(statement, 16, Date().timeIntervalSince1970)
|
||||
sqlite3_bind_int(statement, 16, item.hasChineseAudio ? 1 : 0); sqlite3_bind_int(statement, 17, item.hasChineseSubtitle ? 1 : 0)
|
||||
sqlite3_bind_double(statement, 18, Date().timeIntervalSince1970)
|
||||
guard sqlite3_step(statement) == SQLITE_DONE else { Self.execute(database, "ROLLBACK;"); return }
|
||||
}
|
||||
Self.execute(database, "COMMIT;")
|
||||
@@ -181,6 +188,7 @@ actor MediaLibraryStore {
|
||||
final class MediaNFOParser: NSObject, XMLParserDelegate {
|
||||
private var currentElement = ""
|
||||
private var currentText = ""
|
||||
private var elementStack: [String] = []
|
||||
private var rootElement = ""
|
||||
private var tmdbID: Int?
|
||||
private var title = ""
|
||||
@@ -188,6 +196,8 @@ final class MediaNFOParser: NSObject, XMLParserDelegate {
|
||||
private var overview = ""
|
||||
private var releaseDate = ""
|
||||
private var uniqueIDIsTMDB = false
|
||||
private var hasChineseAudio = false
|
||||
private var hasChineseSubtitle = false
|
||||
|
||||
static func parse(_ data: Data) -> ParsedMediaNFO? {
|
||||
let delegate = MediaNFOParser()
|
||||
@@ -195,12 +205,13 @@ final class MediaNFOParser: NSObject, XMLParserDelegate {
|
||||
parser.delegate = delegate
|
||||
guard parser.parse(), !delegate.title.isEmpty else { return nil }
|
||||
let kind: TMDBMediaKind? = delegate.rootElement == "tvshow" || delegate.rootElement == "episodedetails" ? .tv : (delegate.rootElement == "movie" ? .movie : nil)
|
||||
return ParsedMediaNFO(tmdbID: delegate.tmdbID, title: delegate.title, originalTitle: delegate.originalTitle.isEmpty ? delegate.title : delegate.originalTitle, overview: delegate.overview, releaseDate: delegate.releaseDate, mediaKind: kind)
|
||||
return ParsedMediaNFO(tmdbID: delegate.tmdbID, title: delegate.title, originalTitle: delegate.originalTitle.isEmpty ? delegate.title : delegate.originalTitle, overview: delegate.overview, releaseDate: delegate.releaseDate, mediaKind: kind, hasChineseAudio: delegate.hasChineseAudio, hasChineseSubtitle: delegate.hasChineseSubtitle)
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) {
|
||||
if rootElement.isEmpty { rootElement = elementName.lowercased() }
|
||||
currentElement = elementName.lowercased()
|
||||
elementStack.append(currentElement)
|
||||
currentText = ""
|
||||
uniqueIDIsTMDB = currentElement == "uniqueid" && attributeDict["type"]?.lowercased() == "tmdb"
|
||||
}
|
||||
@@ -209,6 +220,7 @@ final class MediaNFOParser: NSObject, XMLParserDelegate {
|
||||
|
||||
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
|
||||
let value = currentText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let parent = elementStack.dropLast().last
|
||||
switch elementName.lowercased() {
|
||||
case "title": if title.isEmpty { title = value }
|
||||
case "originaltitle": originalTitle = value
|
||||
@@ -217,9 +229,19 @@ final class MediaNFOParser: NSObject, XMLParserDelegate {
|
||||
case "year": if releaseDate.isEmpty { releaseDate = value }
|
||||
case "uniqueid": if uniqueIDIsTMDB { tmdbID = Int(value) }
|
||||
case "tmdbid": tmdbID = Int(value)
|
||||
case "language":
|
||||
let isChinese = Self.isChineseLanguage(value)
|
||||
if parent == "audio" { hasChineseAudio = hasChineseAudio || isChinese }
|
||||
if parent == "subtitle" { hasChineseSubtitle = hasChineseSubtitle || isChinese }
|
||||
default: break
|
||||
}
|
||||
if !elementStack.isEmpty { elementStack.removeLast() }
|
||||
currentText = ""
|
||||
uniqueIDIsTMDB = false
|
||||
}
|
||||
|
||||
private static func isChineseLanguage(_ value: String) -> Bool {
|
||||
let normalized = value.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return ["zh", "zh-cn", "zh-tw", "chi", "zho", "chs", "cht", "chinese", "mandarin", "cantonese", "中文", "国语", "粤语"].contains(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,6 +494,8 @@ struct MediaLibraryItem: Identifiable, Sendable {
|
||||
let overview: String
|
||||
let posterData: Data?
|
||||
let backdropData: Data?
|
||||
let hasChineseAudio: Bool
|
||||
let hasChineseSubtitle: Bool
|
||||
|
||||
var id: String { file.id }
|
||||
var year: String { String(releaseDate.prefix(4)) }
|
||||
@@ -513,6 +515,13 @@ enum MediaLibraryKind: String, CaseIterable, Identifiable, Codable, Sendable {
|
||||
case .mixed: return "混合内容"
|
||||
}
|
||||
}
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .movies: return "film"
|
||||
case .series: return "play.tv"
|
||||
case .mixed: return "rectangle.stack"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaLibraryDefinition: Identifiable, Hashable, Sendable {
|
||||
@@ -535,6 +544,7 @@ struct MediaSidecarFiles: Sendable {
|
||||
let nfo: CloudFile?
|
||||
let poster: CloudFile?
|
||||
let backdrop: CloudFile?
|
||||
let subtitles: [CloudFile]
|
||||
}
|
||||
|
||||
struct MediaScanSnapshot: Sendable {
|
||||
@@ -549,6 +559,8 @@ struct ParsedMediaNFO: Sendable {
|
||||
let overview: String
|
||||
let releaseDate: String
|
||||
let mediaKind: TMDBMediaKind?
|
||||
let hasChineseAudio: Bool
|
||||
let hasChineseSubtitle: Bool
|
||||
}
|
||||
|
||||
struct ParsedMediaName: Sendable {
|
||||
|
||||
Reference in New Issue
Block a user