154 lines
7.1 KiB
Swift
154 lines
7.1 KiB
Swift
import Foundation
|
|
|
|
/// Direct Swift scan of the media roots on plum — a faithful port of
|
|
/// plum-control-mcp's `media/library.ts` (SxxEyy parse, show-root bucketing,
|
|
/// release-noise name normalization). Runs when the NFS `~/media` mount is up;
|
|
/// the JSON snapshot covers the offline case.
|
|
public enum LibraryScanner {
|
|
private static let videoExt: Set<String> = ["mkv", "mp4", "m4v", "avi", "mov", "webm"]
|
|
|
|
// Compiled once. `S(\d{1,2})E(\d{1,3})`, case-insensitive.
|
|
private static let sxxeyy = try! NSRegularExpression(pattern: "S(\\d{1,2})E(\\d{1,3})",
|
|
options: [.caseInsensitive])
|
|
|
|
/// Colon-separated `MEDIA_ROOTS`, default `~/media`.
|
|
public static func mediaRoots() -> [String] {
|
|
if let env = ProcessInfo.processInfo.environment["MEDIA_ROOTS"], !env.isEmpty {
|
|
return env.split(separator: ":").map(String.init).filter { !$0.isEmpty }
|
|
}
|
|
return [FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("media").path]
|
|
}
|
|
|
|
/// True when at least one media root is an existing directory (NFS mounted).
|
|
public static func rootsAvailable() -> Bool {
|
|
let fm = FileManager.default
|
|
var isDir: ObjCBool = false
|
|
return mediaRoots().contains { fm.fileExists(atPath: $0, isDirectory: &isDir) && isDir.boolValue }
|
|
}
|
|
|
|
private struct FoundFile { let path: String; let season: Int; let episode: Int }
|
|
|
|
public static func scan() -> [CachedShow] {
|
|
let fm = FileManager.default
|
|
var shows: [String: CachedShow] = [:]
|
|
|
|
for root in mediaRoots() {
|
|
var isDir: ObjCBool = false
|
|
guard fm.fileExists(atPath: root, isDirectory: &isDir), isDir.boolValue else { continue }
|
|
for f in walkForVideos(root: root, maxDepth: 4) {
|
|
let rootDir = showRoot(for: f.path, mediaRoot: root)
|
|
var show = shows[rootDir] ?? CachedShow(
|
|
name: normalizeShowName((rootDir as NSString).lastPathComponent),
|
|
rootDir: rootDir, episodes: [])
|
|
show.episodes.append(CachedEpisode(
|
|
path: f.path, season: f.season, episode: f.episode,
|
|
label: episodeLabel(f.path)))
|
|
shows[rootDir] = show
|
|
}
|
|
}
|
|
|
|
var out = Array(shows.values)
|
|
for i in out.indices {
|
|
out[i].episodes.sort(by: episodeOrder)
|
|
}
|
|
return out.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
|
}
|
|
|
|
private static func episodeOrder(_ lhs: CachedEpisode, _ rhs: CachedEpisode) -> Bool {
|
|
if lhs.season != rhs.season { return lhs.season < rhs.season }
|
|
return lhs.episode < rhs.episode
|
|
}
|
|
|
|
/// Carry forward poster/overview from a prior snapshot onto a fresh scan,
|
|
/// keyed by rootDir, so a rescan never drops Phase-4 enrichment.
|
|
public static func mergeEnrichment(_ scanned: [CachedShow], from previous: [CachedShow]) -> [CachedShow] {
|
|
let prior = Dictionary(previous.map { ($0.rootDir, $0) }, uniquingKeysWith: { a, _ in a })
|
|
return scanned.map { show in
|
|
guard let old = prior[show.rootDir] else { return show }
|
|
var s = show
|
|
s.posterPath = old.posterPath
|
|
s.overview = old.overview
|
|
// re-attach per-episode metaPath by episode path
|
|
let oldMeta = Dictionary(old.episodes.map { ($0.path, $0.metaPath) }, uniquingKeysWith: { a, _ in a })
|
|
s.episodes = s.episodes.map { ep in
|
|
var e = ep; if let m = oldMeta[ep.path] ?? nil { e.metaPath = m }; return e
|
|
}
|
|
return s
|
|
}
|
|
}
|
|
|
|
// MARK: - walk
|
|
|
|
private static func walkForVideos(root: String, maxDepth: Int) -> [FoundFile] {
|
|
let fm = FileManager.default
|
|
var out: [FoundFile] = []
|
|
var stack: [(dir: String, depth: Int)] = [(root, 0)]
|
|
while let top = stack.popLast() {
|
|
guard let entries = try? fm.contentsOfDirectory(atPath: top.dir) else { continue }
|
|
for name in entries where !name.hasPrefix(".") {
|
|
let full = (top.dir as NSString).appendingPathComponent(name)
|
|
var isDir: ObjCBool = false
|
|
guard fm.fileExists(atPath: full, isDirectory: &isDir) else { continue }
|
|
if isDir.boolValue {
|
|
if top.depth < maxDepth { stack.append((full, top.depth + 1)) }
|
|
} else if videoExt.contains((name as NSString).pathExtension.lowercased()) {
|
|
if let (s, e) = parseSxxEyy(name) {
|
|
out.append(FoundFile(path: full, season: s, episode: e))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
private static func showRoot(for filePath: String, mediaRoot: String) -> String {
|
|
let parent = (filePath as NSString).deletingLastPathComponent
|
|
let parentBase = (parent as NSString).lastPathComponent
|
|
if matches(parentBase, "^(season\\s*\\d+|s\\d{1,2})\\b") || matches(parentBase, "\\.s\\d{1,2}\\.") {
|
|
let grand = (parent as NSString).deletingLastPathComponent
|
|
if grand.hasPrefix(mediaRoot) && grand != mediaRoot { return grand }
|
|
}
|
|
return parent
|
|
}
|
|
|
|
// MARK: - parsing
|
|
|
|
public static func parseSxxEyy(_ name: String) -> (Int, Int)? {
|
|
let range = NSRange(name.startIndex..., in: name)
|
|
guard let m = sxxeyy.firstMatch(in: name, range: range),
|
|
let sR = Range(m.range(at: 1), in: name),
|
|
let eR = Range(m.range(at: 2), in: name),
|
|
let s = Int(name[sR]), let e = Int(name[eR]) else { return nil }
|
|
return (s, e)
|
|
}
|
|
|
|
private static func episodeLabel(_ path: String) -> String {
|
|
let base = (path as NSString).lastPathComponent
|
|
return (base as NSString).deletingPathExtension
|
|
}
|
|
|
|
/// Strip bracketed groups, year-and-after, release-noise-and-after, then tidy
|
|
/// separators. Mirrors `normalizeShowName` in library.ts.
|
|
public static func normalizeShowName(_ dirName: String) -> String {
|
|
var s = dirName
|
|
s = replace(s, "\\[[^\\]]*\\]", " ")
|
|
s = replace(s, "\\([^)]*\\)", " ")
|
|
s = replace(s, "\\b(19|20)\\d{2}\\b.*$", "")
|
|
s = replace(s, "\\b(season\\s*\\d+|s\\d{1,2}|complete|series|repack|bluray|webrip|web-dl|hdtv|dvdrip|x264|x265|h\\.?26[45]|hevc|1080p|720p|480p|tvrip|extras?|batch|commentary)\\b.*$", "")
|
|
s = replace(s, "[._-]+", " ")
|
|
s = replace(s, "\\s+", " ").trimmingCharacters(in: .whitespaces)
|
|
return s.isEmpty ? dirName : s
|
|
}
|
|
|
|
// MARK: - regex helpers
|
|
|
|
private static func matches(_ s: String, _ pattern: String) -> Bool {
|
|
s.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil
|
|
}
|
|
|
|
private static func replace(_ s: String, _ pattern: String, _ with: String) -> String {
|
|
guard let re = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return s }
|
|
let range = NSRange(s.startIndex..., in: s)
|
|
return re.stringByReplacingMatches(in: s, range: range, withTemplate: with)
|
|
}
|
|
}
|