feat: 优先刮削中文影视信息与语言资源

This commit is contained in:
2026-07-16 13:25:54 +08:00
parent 934763c7ae
commit 4256587122
4 changed files with 91 additions and 16 deletions
+48 -7
View File
@@ -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? {
+3 -3
View File
@@ -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)
}
+28 -6
View File
@@ -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)
}
}
+12
View File
@@ -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 {