From 42565871225b37862b8e6b46829a6a8dc77ba422 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Thu, 16 Jul 2026 13:25:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=85=88=E5=88=AE=E5=89=8A?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E5=BD=B1=E8=A7=86=E4=BF=A1=E6=81=AF=E4=B8=8E?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E8=B5=84=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- guangya_mac/AppModel.swift | 55 +++++++++++++++++++++++++---- guangya_mac/GuangyaAPI.swift | 6 ++-- guangya_mac/MediaLibraryStore.swift | 34 ++++++++++++++---- guangya_mac/Models.swift | 12 +++++++ 4 files changed, 91 insertions(+), 16 deletions(-) diff --git a/guangya_mac/AppModel.swift b/guangya_mac/AppModel.swift index cca34ae..4751ebc 100644 --- a/guangya_mac/AppModel.swift +++ b/guangya_mac/AppModel.swift @@ -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 = ["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? { diff --git a/guangya_mac/GuangyaAPI.swift b/guangya_mac/GuangyaAPI.swift index d528a21..b542548 100644 --- a/guangya_mac/GuangyaAPI.swift +++ b/guangya_mac/GuangyaAPI.swift @@ -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) } diff --git a/guangya_mac/MediaLibraryStore.swift b/guangya_mac/MediaLibraryStore.swift index 04193ae..01fadf8 100644 --- a/guangya_mac/MediaLibraryStore.swift +++ b/guangya_mac/MediaLibraryStore.swift @@ -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) + } } diff --git a/guangya_mac/Models.swift b/guangya_mac/Models.swift index 0da09fa..a8494d2 100644 --- a/guangya_mac/Models.swift +++ b/guangya_mac/Models.swift @@ -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 {