getLibrary.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. /*
  2. Movie App - Library Scanner v2
  3. Scanning strategy for root:/Video/ direct children:
  4. • Subfolder has immediate subdirs with videos → type "series" (seasons = those subdirs)
  5. • Subfolder has only direct video files → type "collection" (flat playlist)
  6. • Subfolder is empty / container only → recurse one level deeper
  7. Special folder names (case-insensitive) handled separately:
  8. • "Anime" / ANIME_FOLDER_NAMES → each child is an anime title (type "anime")
  9. • "Movie" / MOVIE_FOLDER_NAMES → each child is a movie entry (type "movie")
  10. Root-level Movie/Movies/Anime folders (not under Video/) are also processed.
  11. Loose video files directly in root:/Video/ → type "short".
  12. Types returned: "series" | "anime" | "movie" | "collection" | "short"
  13. */
  14. includes("common.js");
  15. requirelib("filelib");
  16. requirelib("imagelib");
  17. // ── Helpers ───────────────────────────────────────────────────────────────────
  18. function fileExt(filepath) {
  19. var parts = filepath.split(".");
  20. if (parts.length < 2) { return ""; }
  21. return parts[parts.length - 1].toLowerCase();
  22. }
  23. function basename(filepath) {
  24. var clean = filepath;
  25. if (clean[clean.length - 1] === "/") { clean = clean.slice(0, -1); }
  26. var parts = clean.split("/");
  27. return parts[parts.length - 1];
  28. }
  29. function ensureSlash(path) {
  30. return path[path.length - 1] === "/" ? path : path + "/";
  31. }
  32. function isMovieFolderName(name) {
  33. var lower = name.toLowerCase();
  34. for (var i = 0; i < MOVIE_FOLDER_NAMES.length; i++) {
  35. if (lower === MOVIE_FOLDER_NAMES[i]) { return true; }
  36. }
  37. return false;
  38. }
  39. function isAnimeFolderName(name) {
  40. var lower = name.toLowerCase();
  41. for (var i = 0; i < ANIME_FOLDER_NAMES.length; i++) {
  42. if (lower === ANIME_FOLDER_NAMES[i]) { return true; }
  43. }
  44. return false;
  45. }
  46. function shouldSkipRoot(rootPath) {
  47. var lower = rootPath.toLowerCase();
  48. for (var i = 0; i < SKIP_ROOT_PREFIXES.length; i++) {
  49. if (lower.indexOf(SKIP_ROOT_PREFIXES[i]) === 0) { return true; }
  50. }
  51. return false;
  52. }
  53. // Return video files directly inside a folder (non-recursive, skips macOS forks)
  54. function listVideosInFolder(folderPath) {
  55. var ensure = ensureSlash(folderPath);
  56. var videos = [];
  57. for (var i = 0; i < VALID_VIDEO_FORMATS.length; i++) {
  58. var found = filelib.aglob(ensure + "*." + VALID_VIDEO_FORMATS[i]);
  59. if (found && found.length > 0) {
  60. for (var j = 0; j < found.length; j++) {
  61. if (basename(found[j]).substr(0, 2) !== "._") { videos.push(found[j]); }
  62. }
  63. }
  64. }
  65. return videos;
  66. }
  67. // Return immediate subdirectories only (depth = 1)
  68. function listSubdirs(folderPath) {
  69. var ensure = ensureSlash(folderPath);
  70. var all = filelib.walk(ensure, "folder");
  71. if (!all) { return []; }
  72. var immediate = [];
  73. for (var i = 0; i < all.length; i++) {
  74. var rel = all[i].replace(ensure, "");
  75. var segs = rel.split("/").filter(function (s) { return s.length > 0; });
  76. if (segs.length === 1) { immediate.push(ensureSlash(all[i])); }
  77. }
  78. return immediate;
  79. }
  80. // Load cached thumbnail string for a video file; returns base64 or ""
  81. function getFirstVideoThumb(videoFile) {
  82. if (!videoFile) { return ""; }
  83. var t = imagelib.loadThumbString(videoFile);
  84. if (t !== false && t !== null && typeof t === "string" && t.length > 0) { return t; }
  85. return "";
  86. }
  87. // ── Scanners ──────────────────────────────────────────────────────────────────
  88. // Scan an Anime container: each immediate child becomes one anime title (type "anime")
  89. function scanAnimeContainer(containerPath, results) {
  90. var cEnsure = ensureSlash(containerPath);
  91. var titles = listSubdirs(cEnsure);
  92. for (var i = 0; i < titles.length; i++) {
  93. var titlePath = ensureSlash(titles[i]);
  94. var titleName = basename(titlePath);
  95. var directEps = listVideosInFolder(titlePath);
  96. directEps.sort();
  97. var titleSubdirs = listSubdirs(titlePath);
  98. var seasonsWithVids = [];
  99. for (var j = 0; j < titleSubdirs.length; j++) {
  100. var sv = listVideosInFolder(titleSubdirs[j]);
  101. if (sv.length > 0) {
  102. sv.sort();
  103. seasonsWithVids.push({ folder: ensureSlash(titleSubdirs[j]), videos: sv });
  104. }
  105. }
  106. seasonsWithVids.sort(function(a, b) { return basename(a.folder) < basename(b.folder) ? -1 : 1; });
  107. if (directEps.length > 0) {
  108. results.push({
  109. name: titleName,
  110. type: "anime",
  111. folderpath: titlePath,
  112. thumbnail: getFirstVideoThumb(directEps[0]),
  113. episodeCount: directEps.length,
  114. seasons: [{ name: titleName, folderpath: titlePath, episodeCount: directEps.length }]
  115. });
  116. } else if (seasonsWithVids.length > 0) {
  117. var total = 0;
  118. var seasons = [];
  119. for (var k = 0; k < seasonsWithVids.length; k++) {
  120. total += seasonsWithVids[k].videos.length;
  121. seasons.push({
  122. name: basename(seasonsWithVids[k].folder),
  123. folderpath: seasonsWithVids[k].folder,
  124. episodeCount: seasonsWithVids[k].videos.length
  125. });
  126. }
  127. results.push({
  128. name: titleName,
  129. type: "anime",
  130. folderpath: titlePath,
  131. thumbnail: getFirstVideoThumb(seasonsWithVids[0].videos[0]),
  132. episodeCount: total,
  133. seasons: seasons
  134. });
  135. }
  136. // else: skip empty title folder
  137. }
  138. }
  139. // Scan a Movie container: each immediate child (or loose file) is one movie (type "movie")
  140. function scanMovieContainer(containerPath, results) {
  141. var cEnsure = ensureSlash(containerPath);
  142. var subs = listSubdirs(cEnsure);
  143. for (var i = 0; i < subs.length; i++) {
  144. var sfPath = ensureSlash(subs[i]);
  145. var sfVids = listVideosInFolder(sfPath);
  146. sfVids.sort();
  147. if (sfVids.length > 0) {
  148. results.push({
  149. name: basename(sfPath),
  150. type: "movie",
  151. folderpath: sfPath,
  152. thumbnail: getFirstVideoThumb(sfVids[0]),
  153. episodeCount: sfVids.length,
  154. seasons: []
  155. });
  156. } else {
  157. // One more level (e.g. Movie/Franchise/Film/*.mkv)
  158. var subSubs = listSubdirs(sfPath);
  159. for (var j = 0; j < subSubs.length; j++) {
  160. var ssVids = listVideosInFolder(subSubs[j]);
  161. ssVids.sort();
  162. if (ssVids.length > 0) {
  163. results.push({
  164. name: basename(subSubs[j]),
  165. type: "movie",
  166. folderpath: ensureSlash(subSubs[j]),
  167. thumbnail: getFirstVideoThumb(ssVids[0]),
  168. episodeCount: ssVids.length,
  169. seasons: []
  170. });
  171. }
  172. }
  173. }
  174. }
  175. // Loose single-file movies directly in the container
  176. var loose = listVideosInFolder(cEnsure);
  177. loose.sort();
  178. for (var lv = 0; lv < loose.length; lv++) {
  179. var base = basename(loose[lv]);
  180. var dot = base.lastIndexOf(".");
  181. results.push({
  182. name: dot > 0 ? base.substring(0, dot) : base,
  183. type: "movie",
  184. folderpath: cEnsure,
  185. thumbnail: getFirstVideoThumb(loose[lv]),
  186. episodeCount: 1,
  187. seasons: [],
  188. _singleFile: loose[lv]
  189. });
  190. }
  191. }
  192. // Classify a direct child of the Video/ folder.
  193. //
  194. // subsWithVids.length > 0 → "series" (those subdirs are seasons)
  195. // directVideos.length > 0 → "collection" (flat playlist)
  196. // otherwise → recurse one level (handles containers like Video/Pack/Series/)
  197. //
  198. // depth guards against infinite recursion; max 2 recursive calls from scanRoot.
  199. function classifyVideoFolder(folderPath, results, depth) {
  200. depth = depth || 0;
  201. if (depth > 2) { return; }
  202. var ensure = ensureSlash(folderPath);
  203. var name = basename(ensure);
  204. // Delegate to specialised scanners
  205. if (isAnimeFolderName(name)) { scanAnimeContainer(ensure, results); return; }
  206. if (isMovieFolderName(name)) { scanMovieContainer(ensure, results); return; }
  207. var directVideos = listVideosInFolder(ensure);
  208. directVideos.sort();
  209. var subdirs = listSubdirs(ensure);
  210. // Build list of immediate subdirs that contain video files directly inside them
  211. var subsWithVids = [];
  212. for (var i = 0; i < subdirs.length; i++) {
  213. var sv = listVideosInFolder(subdirs[i]);
  214. if (sv.length > 0) {
  215. sv.sort();
  216. subsWithVids.push({ folder: ensureSlash(subdirs[i]), videos: sv });
  217. }
  218. }
  219. subsWithVids.sort(function(a, b) { return basename(a.folder) < basename(b.folder) ? -1 : 1; });
  220. if (subsWithVids.length > 0) {
  221. // TV series / anime-style: subdirs are seasons
  222. var totalEps = 0;
  223. var seasons = [];
  224. for (var s = 0; s < subsWithVids.length; s++) {
  225. totalEps += subsWithVids[s].videos.length;
  226. seasons.push({
  227. name: basename(subsWithVids[s].folder),
  228. folderpath: subsWithVids[s].folder,
  229. episodeCount: subsWithVids[s].videos.length
  230. });
  231. }
  232. results.push({
  233. name: name,
  234. type: "series",
  235. folderpath: ensure,
  236. thumbnail: getFirstVideoThumb(subsWithVids[0].videos[0]),
  237. episodeCount: totalEps,
  238. seasons: seasons
  239. });
  240. } else if (directVideos.length > 0) {
  241. // Flat playlist: all videos live directly inside this folder
  242. results.push({
  243. name: name,
  244. type: "collection",
  245. folderpath: ensure,
  246. thumbnail: getFirstVideoThumb(directVideos[0]),
  247. episodeCount: directVideos.length,
  248. seasons: []
  249. });
  250. } else {
  251. // Container with no direct videos and no season subdirs → recurse
  252. for (var r = 0; r < subdirs.length; r++) {
  253. classifyVideoFolder(subdirs[r], results, depth + 1);
  254. }
  255. }
  256. }
  257. // ── Root scanner ──────────────────────────────────────────────────────────────
  258. function scanRoot(rootPath) {
  259. var albums = [];
  260. var ensure = ensureSlash(rootPath);
  261. // 1. root:/Video/ — each immediate subfolder becomes a library entry
  262. var videoBase = ensure + VIDEO_FOLDER_NAME + "/";
  263. if (filelib.fileExists(videoBase)) {
  264. var videoDirs = listSubdirs(videoBase);
  265. for (var i = 0; i < videoDirs.length; i++) {
  266. classifyVideoFolder(videoDirs[i], albums, 0);
  267. }
  268. // Loose video files directly in Video/ → individual "short" entries
  269. var looseVideos = listVideosInFolder(videoBase);
  270. looseVideos.sort();
  271. for (var lv = 0; lv < looseVideos.length; lv++) {
  272. var lvBase = basename(looseVideos[lv]);
  273. var lvDot = lvBase.lastIndexOf(".");
  274. albums.push({
  275. name: lvDot > 0 ? lvBase.substring(0, lvDot) : lvBase,
  276. type: "short",
  277. folderpath: videoBase,
  278. thumbnail: getFirstVideoThumb(looseVideos[lv]),
  279. episodeCount: 1,
  280. seasons: [],
  281. _singleFile: looseVideos[lv]
  282. });
  283. }
  284. }
  285. // 2. Root-level Movie/Movies and Anime folders (siblings of Video/)
  286. var rootDirs = listSubdirs(ensure);
  287. for (var d = 0; d < rootDirs.length; d++) {
  288. var dirName = basename(rootDirs[d]);
  289. if (dirName === VIDEO_FOLDER_NAME) { continue; } // already processed above
  290. if (MOVIE_FOLDER_NAMES.indexOf(dirName.toLowerCase()) >= 0) {
  291. scanMovieContainer(rootDirs[d], albums);
  292. }
  293. if (isAnimeFolderName(dirName)) {
  294. scanAnimeContainer(rootDirs[d], albums);
  295. }
  296. }
  297. return albums;
  298. }
  299. // ── Entry point ───────────────────────────────────────────────────────────────
  300. var CACHE_FILE = "user:/.appdata/Movie/library_cache.json";
  301. var STALE_MS = 8 * 60 * 60 * 1000; // 8 hours
  302. function main() {
  303. // ── Serve from cache if it is still fresh and no force-refresh was requested ──
  304. // POST param forceRefresh=1 bypasses this check (sent by the manual Refresh button).
  305. var forced = (typeof forceRefresh !== "undefined" && forceRefresh === "1");
  306. if (!forced && filelib.fileExists(CACHE_FILE)) {
  307. var raw = filelib.readFile(CACHE_FILE);
  308. if (raw && raw !== false && raw.length > 10) {
  309. try {
  310. var cached = JSON.parse(raw);
  311. if (cached && cached.ts && Array.isArray(cached.data) &&
  312. (new Date().getTime() - cached.ts) < STALE_MS) {
  313. sendJSONResp(JSON.stringify(cached.data));
  314. return; // skip the full scan — cache is still fresh
  315. }
  316. } catch (e) {}
  317. }
  318. }
  319. var allAlbums = [];
  320. var roots = filelib.glob("/");
  321. if (!roots) { roots = []; }
  322. for (var i = 0; i < roots.length; i++) {
  323. if (shouldSkipRoot(roots[i])) { continue; }
  324. var found = scanRoot(roots[i]);
  325. for (var j = 0; j < found.length; j++) { allAlbums.push(found[j]); }
  326. }
  327. // Persist to server-side cache so any device (or next session) can load instantly.
  328. // This runs before sendJSONResp so the file is guaranteed written even if the
  329. // client closes the tab before the response arrives.
  330. try {
  331. if (!filelib.fileExists("user:/Document/")) { filelib.mkdir("user:/Document/"); }
  332. if (!filelib.fileExists("user:/.appdata/")) { filelib.mkdir("user:/.appdata/"); }
  333. if (!filelib.fileExists("user:/.appdata/Movie/")) { filelib.mkdir("user:/.appdata/Movie/"); }
  334. filelib.writeFile(
  335. "user:/.appdata/Movie/library_cache.json",
  336. JSON.stringify({ ts: new Date().getTime(), data: allAlbums })
  337. );
  338. } catch (e) {} // never let a cache-write failure break the response
  339. sendJSONResp(JSON.stringify(allAlbums));
  340. }
  341. main();