tv-anarchy/Sources/PlumTVCore/Library/LibraryScanner.swift
Natalie ff6f881648 feat(@applications/plum-tv): add library browsing UI and core library logic
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-07 21:52:49 -07:00

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)
}
}