Przeglądaj źródła

Refactor movie scanner

Toby Chui 3 tygodni temu
rodzic
commit
7a462e3162

+ 3 - 0
src/web/Movie/backend/common.js

@@ -18,6 +18,9 @@ var SCRIPT_GET_LIBRARY   = BACKEND_PATH + "getLibrary.js";
 var SCRIPT_GET_EPISODES  = BACKEND_PATH + "getEpisodes.js";
 var SCRIPT_GET_THUMBNAIL = BACKEND_PATH + "getThumbnail.js";
 var SCRIPT_LIST_FOLDER   = BACKEND_PATH + "listFolder.js";
+var SCRIPT_GET_MOVIE_INFO = BACKEND_PATH + "getMovieInfo.js";
+var SCRIPT_GET_WATCHTIME  = BACKEND_PATH + "getWatchTime.js";
+var SCRIPT_SET_WATCHTIME  = BACKEND_PATH + "setWatchTime.js";
 
 // ── Scanner settings ─────────────────────────────────────────────────────────
 var VALID_VIDEO_FORMATS = ["mp4", "webm", "ogg", "mkv", "avi", "mov", "m4v", "wmv", "flv", "rmvb", "ts"];

+ 190 - 217
src/web/Movie/backend/getLibrary.js

@@ -1,26 +1,22 @@
 /*
-    Movie App - Library Scanner
-    Scans all file-system roots for video content.
-
-    Scanning strategy:
-      • root:/Video/       – scanned normally; subfolders classified as series or movie
-      • root:/Movie/ etc.  – all subfolders forced to type "movie"
-      • Any folder named "Movie" or "Movies" anywhere in the tree is treated the same
-
-    Album object returned:
-    {
-        "name"        : "House MD",
-        "type"        : "series" | "movie",
-        "folderpath"  : "user:/Video/House MD/",
-        "thumbnail"   : "<base64>" | "",
-        "episodeCount": 44,
-        "seasons"     : [              // only for "series"
-            { "name": "Season 1", "folderpath": "…/", "episodeCount": 22 }
-        ]
-    }
+    Movie App - Library Scanner v2
+
+    Scanning strategy for root:/Video/ direct children:
+      • Subfolder has immediate subdirs with videos → type "series"  (seasons = those subdirs)
+      • Subfolder has only direct video files        → type "collection" (flat playlist)
+      • Subfolder is empty / container only         → recurse one level deeper
+
+    Special folder names (case-insensitive) handled separately:
+      • "Anime" / ANIME_FOLDER_NAMES → each child is an anime title  (type "anime")
+      • "Movie" / MOVIE_FOLDER_NAMES → each child is a movie entry   (type "movie")
+
+    Root-level Movie/Movies/Anime folders (not under Video/) are also processed.
+
+    Loose video files directly in root:/Video/ → type "short".
+
+    Types returned: "series" | "anime" | "movie" | "collection" | "short"
 */
 
-// ── Load shared config ────────────────────────────────────────────────────────
 includes("common.js");
 requirelib("filelib");
 requirelib("imagelib");
@@ -68,7 +64,7 @@ function shouldSkipRoot(rootPath) {
     return false;
 }
 
-// Return video files directly inside a folder (non-recursive)
+// Return video files directly inside a folder (non-recursive, skips macOS forks)
 function listVideosInFolder(folderPath) {
     var ensure = ensureSlash(folderPath);
     var videos = [];
@@ -76,18 +72,17 @@ function listVideosInFolder(folderPath) {
         var found = filelib.aglob(ensure + "*." + VALID_VIDEO_FORMATS[i]);
         if (found && found.length > 0) {
             for (var j = 0; j < found.length; j++) {
-                var fname = basename(found[j]);
-                if (fname.substr(0, 2) !== "._") { videos.push(found[j]); }
+                if (basename(found[j]).substr(0, 2) !== "._") { videos.push(found[j]); }
             }
         }
     }
     return videos;
 }
 
-// Return immediate subdirectories of a folder
+// Return immediate subdirectories only (depth = 1)
 function listSubdirs(folderPath) {
     var ensure = ensureSlash(folderPath);
-    var all = filelib.walk(ensure, "folder");
+    var all    = filelib.walk(ensure, "folder");
     if (!all) { return []; }
     var immediate = [];
     for (var i = 0; i < all.length; i++) {
@@ -98,194 +93,193 @@ function listSubdirs(folderPath) {
     return immediate;
 }
 
-// Thumbnail from the first video file (better than folder icon)
+// Load cached thumbnail string for a video file; returns base64 or ""
 function getFirstVideoThumb(videoFile) {
     if (!videoFile) { return ""; }
-    var thumb = imagelib.loadThumbString(videoFile);
-    if (thumb !== false && thumb !== null && typeof thumb === "string" && thumb.length > 0) {
-        return thumb;
-    }
+    var t = imagelib.loadThumbString(videoFile);
+    if (t !== false && t !== null && typeof t === "string" && t.length > 0) { return t; }
     return "";
 }
 
-// ── Recursive folder classifier ───────────────────────────────────────────────
-//
-//   folderPath  – folder to classify
-//   forceMovie  – true when an ancestor "Movie/Movies" folder was detected
-//   results     – array to push result objects into
-//   depth       – recursion guard (max 6 levels)
-//
-function scanAlbumFolder(folderPath, forceMovie, results, depth) {
-    depth = depth || 0;
-    if (depth > 6) { return; }
-
-    var ensure = ensureSlash(folderPath);
-    var name   = basename(ensure);
-
-    // ── If THIS folder is named "Anime", each subfolder is its own series ────
-    if (isAnimeFolderName(name)) {
-        var animeSubs = listSubdirs(ensure);
-        for (var ai = 0; ai < animeSubs.length; ai++) {
-            var titlePath = ensureSlash(animeSubs[ai]);
-            var titleName = basename(titlePath);
-            var directEps = listVideosInFolder(titlePath);
-            directEps.sort();
-            var titleDirs = listSubdirs(titlePath);
-            var titleSeasonsWithVids = [];
-            for (var tj = 0; tj < titleDirs.length; tj++) {
-                var sv = listVideosInFolder(titleDirs[tj]);
-                if (sv.length > 0) {
-                    sv.sort();
-                    titleSeasonsWithVids.push({ folder: titleDirs[tj], videos: sv });
-                }
+// ── Scanners ──────────────────────────────────────────────────────────────────
+
+// Scan an Anime container: each immediate child becomes one anime title (type "anime")
+function scanAnimeContainer(containerPath, results) {
+    var cEnsure = ensureSlash(containerPath);
+    var titles  = listSubdirs(cEnsure);
+    for (var i = 0; i < titles.length; i++) {
+        var titlePath = ensureSlash(titles[i]);
+        var titleName = basename(titlePath);
+        var directEps = listVideosInFolder(titlePath);
+        directEps.sort();
+
+        var titleSubdirs    = listSubdirs(titlePath);
+        var seasonsWithVids = [];
+        for (var j = 0; j < titleSubdirs.length; j++) {
+            var sv = listVideosInFolder(titleSubdirs[j]);
+            if (sv.length > 0) {
+                sv.sort();
+                seasonsWithVids.push({ folder: ensureSlash(titleSubdirs[j]), videos: sv });
             }
-            titleSeasonsWithVids.sort(function (a, b) {
-                return basename(a.folder) < basename(b.folder) ? -1 : 1;
+        }
+        seasonsWithVids.sort(function(a, b) { return basename(a.folder) < basename(b.folder) ? -1 : 1; });
+
+        if (directEps.length > 0) {
+            results.push({
+                name:         titleName,
+                type:         "anime",
+                folderpath:   titlePath,
+                thumbnail:    getFirstVideoThumb(directEps[0]),
+                episodeCount: directEps.length,
+                seasons:      [{ name: titleName, folderpath: titlePath, episodeCount: directEps.length }]
             });
-            if (directEps.length > 0) {
-                // Episodes sitting directly in the title folder → one implicit season
-                results.push({
-                    name:         titleName,
-                    type:         "anime",
-                    folderpath:   titlePath,
-                    thumbnail:    getFirstVideoThumb(directEps[0]),
-                    episodeCount: directEps.length,
-                    seasons:      [{ name: titleName, folderpath: titlePath, episodeCount: directEps.length }]
-                });
-            } else if (titleSeasonsWithVids.length > 0) {
-                // Season sub-folders found
-                var aTotalEps = 0;
-                var aSeasons  = [];
-                for (var ts = 0; ts < titleSeasonsWithVids.length; ts++) {
-                    aTotalEps += titleSeasonsWithVids[ts].videos.length;
-                    aSeasons.push({
-                        name:         basename(titleSeasonsWithVids[ts].folder),
-                        folderpath:   titleSeasonsWithVids[ts].folder,
-                        episodeCount: titleSeasonsWithVids[ts].videos.length
-                    });
-                }
-                results.push({
-                    name:         titleName,
-                    type:         "anime",
-                    folderpath:   titlePath,
-                    thumbnail:    getFirstVideoThumb(titleSeasonsWithVids[0].videos[0]),
-                    episodeCount: aTotalEps,
-                    seasons:      aSeasons
+        } else if (seasonsWithVids.length > 0) {
+            var total   = 0;
+            var seasons = [];
+            for (var k = 0; k < seasonsWithVids.length; k++) {
+                total += seasonsWithVids[k].videos.length;
+                seasons.push({
+                    name:         basename(seasonsWithVids[k].folder),
+                    folderpath:   seasonsWithVids[k].folder,
+                    episodeCount: seasonsWithVids[k].videos.length
                 });
-            } else {
-                // Empty or nested structure – fall back to normal scanning
-                scanAlbumFolder(titlePath, false, results, depth + 1);
             }
+            results.push({
+                name:         titleName,
+                type:         "anime",
+                folderpath:   titlePath,
+                thumbnail:    getFirstVideoThumb(seasonsWithVids[0].videos[0]),
+                episodeCount: total,
+                seasons:      seasons
+            });
         }
-        return;
+        // else: skip empty title folder
     }
+}
 
-    // ── If THIS folder is named "Movie/Movies", scan its children as movies ──
-    if (isMovieFolderName(name)) {
-        var subs = listSubdirs(ensure);
-        for (var i = 0; i < subs.length; i++) {
-            scanAlbumFolder(subs[i], true, results, depth + 1);
-        }
-        // Loose videos directly inside the Movie folder
-        var looseVids = listVideosInFolder(ensure);
-        looseVids.sort();
-        for (var lv = 0; lv < looseVids.length; lv++) {
-            var lvName   = basename(looseVids[lv]);
-            var dot      = lvName.lastIndexOf(".");
-            var displayN = dot > 0 ? lvName.substring(0, dot) : lvName;
+// Scan a Movie container: each immediate child (or loose file) is one movie (type "movie")
+function scanMovieContainer(containerPath, results) {
+    var cEnsure = ensureSlash(containerPath);
+    var subs    = listSubdirs(cEnsure);
+    for (var i = 0; i < subs.length; i++) {
+        var sfPath = ensureSlash(subs[i]);
+        var sfVids = listVideosInFolder(sfPath);
+        sfVids.sort();
+        if (sfVids.length > 0) {
             results.push({
-                name:         displayN,
+                name:         basename(sfPath),
                 type:         "movie",
-                folderpath:   ensure,
-                thumbnail:    getFirstVideoThumb(looseVids[lv]),
-                episodeCount: 1,
-                seasons:      [],
-                _singleFile:  looseVids[lv]
+                folderpath:   sfPath,
+                thumbnail:    getFirstVideoThumb(sfVids[0]),
+                episodeCount: sfVids.length,
+                seasons:      []
             });
+        } else {
+            // One more level (e.g. Movie/Franchise/Film/*.mkv)
+            var subSubs = listSubdirs(sfPath);
+            for (var j = 0; j < subSubs.length; j++) {
+                var ssVids = listVideosInFolder(subSubs[j]);
+                ssVids.sort();
+                if (ssVids.length > 0) {
+                    results.push({
+                        name:         basename(subSubs[j]),
+                        type:         "movie",
+                        folderpath:   ensureSlash(subSubs[j]),
+                        thumbnail:    getFirstVideoThumb(ssVids[0]),
+                        episodeCount: ssVids.length,
+                        seasons:      []
+                    });
+                }
+            }
         }
-        return;
     }
+    // Loose single-file movies directly in the container
+    var loose = listVideosInFolder(cEnsure);
+    loose.sort();
+    for (var lv = 0; lv < loose.length; lv++) {
+        var base = basename(loose[lv]);
+        var dot  = base.lastIndexOf(".");
+        results.push({
+            name:         dot > 0 ? base.substring(0, dot) : base,
+            type:         "movie",
+            folderpath:   cEnsure,
+            thumbnail:    getFirstVideoThumb(loose[lv]),
+            episodeCount: 1,
+            seasons:      [],
+            _singleFile:  loose[lv]
+        });
+    }
+}
+
+// Classify a direct child of the Video/ folder.
+//
+//   subsWithVids.length > 0  →  "series"     (those subdirs are seasons)
+//   directVideos.length > 0  →  "collection" (flat playlist)
+//   otherwise                →  recurse one level (handles containers like Video/Pack/Series/)
+//
+// depth guards against infinite recursion; max 2 recursive calls from scanRoot.
+function classifyVideoFolder(folderPath, results, depth) {
+    depth = depth || 0;
+    if (depth > 2) { return; }
+
+    var ensure = ensureSlash(folderPath);
+    var name   = basename(ensure);
+
+    // Delegate to specialised scanners
+    if (isAnimeFolderName(name)) { scanAnimeContainer(ensure, results); return; }
+    if (isMovieFolderName(name)) { scanMovieContainer(ensure, results); return; }
 
-    // ── Gather direct content ─────────────────────────────────────────────────
     var directVideos = listVideosInFolder(ensure);
     directVideos.sort();
 
     var subdirs = listSubdirs(ensure);
 
-    // Find sub-folders that have videos directly inside them
-    var subsWithVideos = [];
-    for (var k = 0; k < subdirs.length; k++) {
-        var sf    = subdirs[k];
-        var svids = listVideosInFolder(sf);
-        svids.sort();
-        if (svids.length > 0) {
-            subsWithVideos.push({ folder: sf, videos: svids });
+    // Build list of immediate subdirs that contain video files directly inside them
+    var subsWithVids = [];
+    for (var i = 0; i < subdirs.length; i++) {
+        var sv = listVideosInFolder(subdirs[i]);
+        if (sv.length > 0) {
+            sv.sort();
+            subsWithVids.push({ folder: ensureSlash(subdirs[i]), videos: sv });
         }
     }
-    // Sort seasons naturally by name
-    subsWithVideos.sort(function (a, b) {
-        return basename(a.folder) < basename(b.folder) ? -1 : 1;
-    });
-
-    // ── Case 1: folder has direct videos → movie/album ────────────────────────
-    if (directVideos.length > 0) {
+    subsWithVids.sort(function(a, b) { return basename(a.folder) < basename(b.folder) ? -1 : 1; });
+
+    if (subsWithVids.length > 0) {
+        // TV series / anime-style: subdirs are seasons
+        var totalEps = 0;
+        var seasons  = [];
+        for (var s = 0; s < subsWithVids.length; s++) {
+            totalEps += subsWithVids[s].videos.length;
+            seasons.push({
+                name:         basename(subsWithVids[s].folder),
+                folderpath:   subsWithVids[s].folder,
+                episodeCount: subsWithVids[s].videos.length
+            });
+        }
         results.push({
             name:         name,
-            type:         "movie",
+            type:         "series",
+            folderpath:   ensure,
+            thumbnail:    getFirstVideoThumb(subsWithVids[0].videos[0]),
+            episodeCount: totalEps,
+            seasons:      seasons
+        });
+    } else if (directVideos.length > 0) {
+        // Flat playlist: all videos live directly inside this folder
+        results.push({
+            name:         name,
+            type:         "collection",
             folderpath:   ensure,
             thumbnail:    getFirstVideoThumb(directVideos[0]),
             episodeCount: directVideos.length,
             seasons:      []
         });
-        return;
-    }
-
-    // ── Case 2: sub-folders contain videos ────────────────────────────────────
-    if (subsWithVideos.length > 0) {
-        if (forceMovie) {
-            // Movie context → each sub-folder is a separate movie
-            for (var m = 0; m < subsWithVideos.length; m++) {
-                var sfF = subsWithVideos[m].folder;
-                var sfV = subsWithVideos[m].videos;
-                results.push({
-                    name:         basename(sfF),
-                    type:         "movie",
-                    folderpath:   sfF,
-                    thumbnail:    sfV.length > 0 ? getFirstVideoThumb(sfV[0]) : "",
-                    episodeCount: sfV.length,
-                    seasons:      []
-                });
-            }
-        } else {
-            // Normal context → TV series with seasons
-            var totalEps = 0;
-            var seasons  = [];
-            for (var s = 0; s < subsWithVideos.length; s++) {
-                totalEps += subsWithVideos[s].videos.length;
-                seasons.push({
-                    name:         basename(subsWithVideos[s].folder),
-                    folderpath:   subsWithVideos[s].folder,
-                    episodeCount: subsWithVideos[s].videos.length
-                });
-            }
-            // Thumbnail: first video of first (alphabetically first) season
-            var seriesThumb = subsWithVideos[0].videos.length > 0
-                ? getFirstVideoThumb(subsWithVideos[0].videos[0]) : "";
-            results.push({
-                name:         name,
-                type:         "series",
-                folderpath:   ensure,
-                thumbnail:    seriesThumb,
-                episodeCount: totalEps,
-                seasons:      seasons
-            });
+    } else {
+        // Container with no direct videos and no season subdirs → recurse
+        for (var r = 0; r < subdirs.length; r++) {
+            classifyVideoFolder(subdirs[r], results, depth + 1);
         }
-        return;
-    }
-
-    // ── Case 3: empty / container folder → recurse into sub-folders ───────────
-    for (var r = 0; r < subdirs.length; r++) {
-        scanAlbumFolder(subdirs[r], forceMovie, results, depth + 1);
     }
 }
 
@@ -295,22 +289,21 @@ function scanRoot(rootPath) {
     var albums = [];
     var ensure = ensureSlash(rootPath);
 
-    // 1. Scan root:/Video/ (primary video library)
+    // 1. root:/Video/ — each immediate subfolder becomes a library entry
     var videoBase = ensure + VIDEO_FOLDER_NAME + "/";
     if (filelib.fileExists(videoBase)) {
         var videoDirs = listSubdirs(videoBase);
         for (var i = 0; i < videoDirs.length; i++) {
-            scanAlbumFolder(videoDirs[i], false, albums, 0);
+            classifyVideoFolder(videoDirs[i], albums, 0);
         }
-        // Loose videos sitting directly in Video/ → individual "short" entries
+        // Loose video files directly in Video/ → individual "short" entries
         var looseVideos = listVideosInFolder(videoBase);
         looseVideos.sort();
         for (var lv = 0; lv < looseVideos.length; lv++) {
-            var lvName = basename(looseVideos[lv]);
-            var lvDot  = lvName.lastIndexOf(".");
-            var lvDisp = lvDot > 0 ? lvName.substring(0, lvDot) : lvName;
+            var lvBase = basename(looseVideos[lv]);
+            var lvDot  = lvBase.lastIndexOf(".");
             albums.push({
-                name:         lvDisp,
+                name:         lvDot > 0 ? lvBase.substring(0, lvDot) : lvBase,
                 type:         "short",
                 folderpath:   videoBase,
                 thumbnail:    getFirstVideoThumb(looseVideos[lv]),
@@ -321,35 +314,16 @@ function scanRoot(rootPath) {
         }
     }
 
-    // 2. Scan any root-level Movie/Movies folders (e.g. disk:/Movie/)
+    // 2. Root-level Movie/Movies and Anime folders (siblings of Video/)
     var rootDirs = listSubdirs(ensure);
     for (var d = 0; d < rootDirs.length; d++) {
-        var dirName      = basename(rootDirs[d]);
-        var dirNameLower = dirName.toLowerCase();
-        // Skip the Video folder (already handled above)
-        if (dirName === VIDEO_FOLDER_NAME) { continue; }
-        if (MOVIE_FOLDER_NAMES.indexOf(dirNameLower) >= 0) {
-            var movieDirs = listSubdirs(rootDirs[d]);
-            for (var m = 0; m < movieDirs.length; m++) {
-                scanAlbumFolder(movieDirs[m], true, albums, 0);
-            }
-            // Loose videos directly in root:/Movie/
-            var looseMovies = listVideosInFolder(rootDirs[d]);
-            looseMovies.sort();
-            for (var lm = 0; lm < looseMovies.length; lm++) {
-                var lmName = basename(looseMovies[lm]);
-                var lmDot  = lmName.lastIndexOf(".");
-                var dispN  = lmDot > 0 ? lmName.substring(0, lmDot) : lmName;
-                albums.push({
-                    name:         dispN,
-                    type:         "movie",
-                    folderpath:   rootDirs[d],
-                    thumbnail:    getFirstVideoThumb(looseMovies[lm]),
-                    episodeCount: 1,
-                    seasons:      [],
-                    _singleFile:  looseMovies[lm]
-                });
-            }
+        var dirName = basename(rootDirs[d]);
+        if (dirName === VIDEO_FOLDER_NAME) { continue; } // already processed above
+        if (MOVIE_FOLDER_NAMES.indexOf(dirName.toLowerCase()) >= 0) {
+            scanMovieContainer(rootDirs[d], albums);
+        }
+        if (isAnimeFolderName(dirName)) {
+            scanAnimeContainer(rootDirs[d], albums);
         }
     }
 
@@ -364,9 +338,8 @@ function main() {
     if (!roots) { roots = []; }
 
     for (var i = 0; i < roots.length; i++) {
-        var root = roots[i];
-        if (shouldSkipRoot(root)) { continue; }
-        var found = scanRoot(root);
+        if (shouldSkipRoot(roots[i])) { continue; }
+        var found = scanRoot(roots[i]);
         for (var j = 0; j < found.length; j++) { allAlbums.push(found[j]); }
     }
 

+ 191 - 0
src/web/Movie/backend/getMovieInfo.js

@@ -0,0 +1,191 @@
+/*
+    Movie App - IMDB Info Fetcher
+    Searches the IMDB API for a movie by title and caches the result locally.
+
+    POST params:
+        movie – movie title to search for
+
+    Cache: user:/Document/Appdata/Movie/{sanitized_title}.json
+    Returns JSON: first IMDB search result, or { error: "..." }
+
+    Result fields (from iamidiotareyoutoo.com search API):
+        #TITLE, #YEAR, #IMDB_ID, #ACTORS, #AKA, #IMDB_URL, #IMG_POSTER,
+        photo_width, photo_height
+*/
+
+includes("common.js");
+requirelib("filelib");
+requirelib("http");
+
+var CACHE_DIR = "user:/Document/Appdata/Movie/";
+
+// Sanitize a string into a safe filename
+function sanitize(str) {
+    var out = "";
+    var s   = str.toLowerCase();
+    for (var i = 0; i < s.length; i++) {
+        var c = s[i];
+        if ((c >= "a" && c <= "z") || (c >= "0" && c <= "9")) { out += c; }
+        else { out += "_"; }
+    }
+    // Collapse runs of underscores
+    while (out.indexOf("__") >= 0) { out = out.split("__").join("_"); }
+    return out.substring(0, 80);
+}
+
+// Create a directory only if it does not already exist
+function mkdirIfMissing(path) {
+    if (!filelib.fileExists(path)) { filelib.mkdir(path); }
+}
+
+// Minimal URL-safe encoding for a query string value
+function urlEncode(str) {
+    var out = "";
+    for (var i = 0; i < str.length; i++) {
+        var c = str[i];
+        if (c === " ") { out += "+"; }
+        else if (
+            (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") ||
+            (c >= "0" && c <= "9") || c === "-" || c === "_" || c === "." || c === "~"
+        ) { out += c; }
+        else { out += encodeURIComponent(c); }
+    }
+    return out;
+}
+
+// Recognised technical tokens that appear after the release year in scene filenames.
+// Shared by extractMovieTitle() and extractYear() so both use identical logic.
+var TECH_TOKEN =
+    "\\d{3,4}[pP]|4[kK]|2160[pP]|" +
+    "BluRay|Blu-Ray|BRRip|BDRip|BDRemux|REMUX|" +
+    "WEB-DL|WEBRip|WEB|AMZN|NF|HULU|DSNP|ATVP|PCOK|U-NEXT|IMAX|" +
+    "DVDRip|DVDScr|HDTV|PDTV|DSRip|HC|" +
+    "x264|x265|X264|X265|XViD|DivX|HEVC|AVC|H\\.264|H\\.265|H264|H265|" +
+    "AAC|AC3|DTS|MP3|FLAC|DD5|DD\\+|TrueHD|Atmos|" +
+    "PROPER|REPACK|EXTENDED|THEATRICAL|LIMITED|INTERNAL|READNFO|DC|3D|" +
+    "YIFY|YTS|RARBG|ETRG|FGT|SPARKS|NTG|GECKOS|VISUM|FraMeSToR";
+
+// Shared preprocessing: normalise separators and glued years so both
+// extractMovieTitle() and extractYear() operate on the same clean string.
+function preprocessName(raw) {
+    var s = raw;
+    s = s.replace(/\[[^\]]*\]/g, " ").replace(/\([^)]*\)/g, " ");
+    if ((s.match(/[A-Za-z0-9]\.[A-Za-z0-9]/g) || []).length >= 3) {
+        s = s.split(".").join(" ");
+    }
+    s = s.split("_").join(" ").replace(/\s+/g, " ").trim();
+    // Insert space before a year glued to a word (Movie2023 → Movie 2023)
+    s = s.replace(/([A-Za-z])((?:19|20)\d{2})\b/g, "$1 $2");
+    return s;
+}
+
+/*
+    Extract a clean movie title from a scene-release filename / folder name.
+
+    Strategy:
+        1-4. Shared preprocessing (preprocessName)
+        5.   Find the FIRST year followed by a technical token → cut there.
+             This avoids cutting on a year that IS the title (e.g. "1917 2019 1080p").
+        6.   Fall back to any standalone four-digit year.
+        7.   No year: strip residual tech tokens in-place.
+*/
+function extractMovieTitle(raw) {
+    var s      = preprocessName(raw);
+    var techRe = new RegExp("\\b((?:19|20)\\d{2})\\s+(?:" + TECH_TOKEN + ")\\b", "i");
+    var m      = s.match(techRe);
+    if (m) {
+        s = s.substring(0, m.index).trim();
+    } else {
+        var ym = s.match(/\b((?:19|20)\d{2})\b/);
+        if (ym) {
+            s = s.substring(0, ym.index).trim();
+        } else {
+            var strips = [
+                /\b\d{3,4}[pP]\b/g,
+                /\b4[kK]\b/g,
+                new RegExp("\\b(?:" + TECH_TOKEN + ")\\b", "gi"),
+                /\s*-\s*[A-Z0-9]{2,}$/i
+            ];
+            for (var i = 0; i < strips.length; i++) { s = s.replace(strips[i], " "); }
+        }
+    }
+    s = s.replace(/\s+/g, " ").trim().replace(/^[-.\s]+/, "").replace(/[-.\s]+$/, "").trim();
+    return s.length > 0 ? s : raw;
+}
+
+/*
+    Extract the release year embedded in a filename, or null if none found.
+    Uses the same preprocessing and priority as extractMovieTitle so the two
+    functions always agree on which number is the year.
+*/
+function extractYear(raw) {
+    var s      = preprocessName(raw);
+    var techRe = new RegExp("\\b((?:19|20)\\d{2})\\s+(?:" + TECH_TOKEN + ")\\b", "i");
+    var m      = s.match(techRe);
+    if (m) { return m[1]; }
+    var ym = s.match(/\b((?:19|20)\d{2})\b/);
+    if (ym) { return ym[1]; }
+    return null;
+}
+
+function main() {
+    if (!movie || movie === "undefined" || movie.length === 0) {
+        sendJSONResp(JSON.stringify({ error: "Missing movie name" }));
+        return;
+    }
+
+    // Ensure cache hierarchy exists
+    mkdirIfMissing("user:/Document/");
+    mkdirIfMissing("user:/Document/Appdata/");
+    mkdirIfMissing(CACHE_DIR);
+
+    var cacheFile = CACHE_DIR + sanitize(movie) + ".json";
+
+    // Return from cache if available
+    if (filelib.fileExists(cacheFile)) {
+        var cached = filelib.readFile(cacheFile);
+        if (cached !== false && cached.length > 2) {
+            sendJSONResp(cached);   // must use sendJSONResp so jQuery parses it as an object
+            return;
+        }
+    }
+
+    var cleanTitle = extractMovieTitle(movie);
+    var filmYear   = extractYear(movie);   // e.g. "1951", or null
+
+    // Fetch from IMDB search API
+    var url  = "https://imdb.iamidiotareyoutoo.com/search?q=" + urlEncode(cleanTitle);
+    var resp = http.get(url);
+    if (!resp || resp === false || resp.length === 0) {
+        sendJSONResp(JSON.stringify({ error: "API unreachable" }));
+        return;
+    }
+
+    var data;
+    try { data = JSON.parse(resp); } catch (e) {
+        sendJSONResp(JSON.stringify({ error: "Invalid API response" }));
+        return;
+    }
+
+    if (!data.ok || !data.description || data.description.length === 0) {
+        sendJSONResp(JSON.stringify({ error: "not_found" }));
+        return;
+    }
+
+    // Pick the result whose year matches the filename year (if we detected one).
+    // Fall back to the first result when there is no year or no match.
+    var result = data.description[0];
+    if (filmYear && data.description.length > 1) {
+        for (var ri = 0; ri < data.description.length; ri++) {
+            if (String(data.description[ri]["#YEAR"]) === filmYear) {
+                result = data.description[ri];
+                break;
+            }
+        }
+    }
+
+    filelib.writeFile(cacheFile, JSON.stringify(result));
+    sendJSONResp(JSON.stringify(result));
+}
+
+main();

+ 31 - 0
src/web/Movie/backend/getWatchTime.js

@@ -0,0 +1,31 @@
+/*
+    Movie App - Get Watch Position
+    Returns the saved playback position for a video file.
+
+    POST params:
+        filepath – virtual path of the video file
+
+    Returns JSON: { position: <seconds>, duration: <seconds>, ts: <unix-ms> }
+    or { error: "no_data" }
+*/
+
+includes("common.js");
+
+function main() {
+    if (!filepath || filepath === "undefined" || filepath.length === 0) {
+        sendJSONResp(JSON.stringify({ error: "Missing filepath" }));
+        return;
+    }
+
+    newDBTableIfNotExists("movie_watchtime");
+    var stored = readDBItem("movie_watchtime", filepath);
+
+    if (!stored || stored === false || stored.length === 0) {
+        sendJSONResp(JSON.stringify({ error: "no_data" }));
+        return;
+    }
+
+    sendResp(stored); // already a JSON string
+}
+
+main();

+ 40 - 0
src/web/Movie/backend/setWatchTime.js

@@ -0,0 +1,40 @@
+/*
+    Movie App - Save Watch Position
+    Stores or clears the playback position for a video file.
+    Pass position=0 to clear (video finished or user reset).
+
+    POST params:
+        filepath – virtual path of the video file
+        position – current time in seconds  (0 = clear the entry)
+        duration – total duration in seconds
+*/
+
+includes("common.js");
+
+function main() {
+    if (!filepath || filepath === "undefined" || filepath.length === 0) {
+        sendJSONResp(JSON.stringify({ error: "Missing filepath" }));
+        return;
+    }
+
+    var pos = parseInt(position, 10);
+    var dur = parseInt(duration,  10);
+    if (isNaN(pos)) { pos = 0; }
+    if (isNaN(dur)) { dur = 0; }
+
+    newDBTableIfNotExists("movie_watchtime");
+
+    if (pos <= 0) {
+        deleteDBItem("movie_watchtime", filepath);
+    } else {
+        writeDBItem("movie_watchtime", filepath, JSON.stringify({
+            position: pos,
+            duration: dur,
+            ts:       new Date().getTime()
+        }));
+    }
+
+    sendOK();
+}
+
+main();

+ 474 - 68
src/web/Movie/index.html

@@ -264,12 +264,16 @@ html, body {
 #season-tabs {
     flex-shrink: 0;
     display: flex;
-    gap: 8px;
-    padding: 12px 28px 0;
-    overflow-x: auto;
-    scrollbar-width: none;
+    flex-wrap: wrap;
+    gap: 6px 8px;
+    padding: 10px 28px 8px;
+    max-height: 120px;    /* ~3 rows before scrolling */
+    overflow-y: auto;
+    scrollbar-width: thin;
+    scrollbar-color: var(--surface2) transparent;
 }
-#season-tabs::-webkit-scrollbar { display: none; }
+#season-tabs::-webkit-scrollbar { width: 3px; height: 3px; }
+#season-tabs::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 3px; }
 
 .season-tab {
     flex-shrink: 0;
@@ -620,6 +624,104 @@ html, body {
     font-size: 32px; user-select: none;
 }
 
+/* ─── Movie info panel ───────────────────────────────────────────────────────── */
+#view-movie-info { position: relative; }
+#movie-info-backdrop {
+    position: absolute; inset: 0;
+    background-size: cover; background-position: center 20%;
+    filter: blur(4px) brightness(0.38);
+    transform: scale(1.12); z-index: 0;
+}
+#movie-info-back {
+    position: absolute; top: 14px; left: 16px; z-index: 10;
+    background: rgba(0,0,0,0.5); backdrop-filter: blur(8px);
+    border: none; cursor: pointer; border-radius: 20px;
+    color: var(--text); font-size: 13px; font-weight: 500;
+    padding: 6px 16px; transition: background var(--transition); outline: none;
+}
+#movie-info-back:hover { background: var(--accent); }
+#movie-info-scroll {
+    flex: 1; overflow-y: auto; overflow-x: hidden;
+    scroll-behavior: smooth; position: relative; z-index: 1;
+}
+#movie-info-scroll::-webkit-scrollbar { width: 4px; }
+#movie-info-scroll::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 4px; }
+#movie-info-hero {
+    min-height: 280px; display: flex; align-items: flex-end; gap: 24px;
+    padding: 70px 32px 28px;
+}
+#movie-info-poster-wrap {
+    width: 130px; flex-shrink: 0; border-radius: 10px; overflow: hidden;
+    box-shadow: 0 12px 32px rgba(0,0,0,0.7);
+    aspect-ratio: 2/3; background: var(--surface2);
+}
+#movie-info-poster-wrap img { width: 100%; height: 100%; object-fit: cover; display: block; }
+#movie-info-meta { flex: 1; min-width: 0; }
+#movie-info-title    { font-size: 26px; font-weight: 700; line-height: 1.15; margin-bottom: 2px; }
+#movie-info-filename {
+    font-size: 11px; color: var(--text-sub); opacity: 0.6;
+    font-style: italic; margin-bottom: 6px;
+    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+    display: none; /* shown only after IMDB title replaces the album name */
+}
+#movie-info-year  { font-size: 14px; color: var(--text-sub); margin-bottom: 8px; }
+#movie-info-cast  { font-size: 13px; color: var(--text-sub); line-height: 1.5; margin-bottom: 14px; }
+#movie-info-actions { display: flex; gap: 10px; flex-wrap: wrap; }
+.yt-badge {
+    display: inline-flex; align-items: center;
+    background: #ff0000; color: #fff;
+    border-radius: 5px; font-size: 12px; font-weight: 800;
+    padding: 5px 9px; cursor: pointer; border: none; outline: none;
+    transition: opacity var(--transition);
+}
+.yt-badge:hover { opacity: 0.8; }
+.imdb-badge {
+    display: inline-flex; align-items: center;
+    background: #f5c518; color: #000;
+    border-radius: 5px; font-size: 12px; font-weight: 800;
+    padding: 5px 9px; cursor: pointer; border: none; outline: none;
+    transition: opacity var(--transition);
+}
+.imdb-badge:hover { opacity: 0.8; }
+#movie-info-loading {
+    display: none; flex-direction: column; align-items: center;
+    justify-content: center; padding: 28px 0; gap: 10px; color: var(--text-sub);
+}
+#movie-info-loading.active { display: flex; }
+#movie-info-files { padding: 0 20px 48px; }
+@media (max-width: 600px) {
+    #movie-info-hero { flex-direction: column; align-items: flex-start; padding: 56px 16px 20px; }
+    #movie-info-poster-wrap { width: 90px; }
+    #movie-info-title { font-size: 20px; }
+    #movie-info-files { padding: 0 12px 48px; }
+}
+
+/* ─── Resume popup ───────────────────────────────────────────────────────────── */
+#resume-popup {
+    display: none;
+    position: absolute; bottom: 90px; right: 20px; z-index: 25;
+    background: rgba(22,22,24,0.97); backdrop-filter: blur(16px);
+    border-radius: 12px; padding: 16px 18px; min-width: 240px;
+    box-shadow: 0 4px 24px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.1);
+    animation: slideUpPopup 0.2s ease;
+}
+#resume-popup.active { display: block; }
+@keyframes slideUpPopup {
+    from { opacity: 0; transform: translateY(10px); }
+    to   { opacity: 1; transform: translateY(0); }
+}
+#resume-popup-title { font-size: 13px; font-weight: 600; margin-bottom: 4px; }
+#resume-popup-sub   { font-size: 12px; color: var(--text-sub); margin-bottom: 12px; }
+#resume-popup-btns  { display: flex; gap: 8px; }
+.resume-btn {
+    flex: 1; padding: 8px; border: none; cursor: pointer;
+    border-radius: 7px; font-size: 12px; font-weight: 600;
+    outline: none; transition: opacity var(--transition);
+}
+.resume-btn:hover { opacity: 0.8; }
+#resume-btn-continue { background: var(--accent); color: #fff; }
+#resume-btn-restart  { background: var(--surface2); color: var(--text); }
+
 /* Focus ring for TV remote navigation */
 .tv-focused {
     outline: 3px solid var(--accent) !important;
@@ -804,6 +906,11 @@ html, body {
                 <div id="movies-grid" class="album-grid"></div>
                 <div class="load-more-wrap"><button class="load-more-btn" id="movies-load-more" style="display:none" onclick="loadMoreSection('movies')">Load more</button></div>
             </div>
+            <div id="collections-section" style="display:none">
+                <div class="section-title">Collections</div>
+                <div id="collections-grid" class="album-grid"></div>
+                <div class="load-more-wrap"><button class="load-more-btn" id="collections-load-more" style="display:none" onclick="loadMoreSection('collections')">Load more</button></div>
+            </div>
             <div id="series-section" style="display:none">
                 <div class="section-title">TV / Shows</div>
                 <div id="series-grid" class="album-grid"></div>
@@ -847,6 +954,36 @@ html, body {
         </div>
     </div>
 
+    <!-- ═══════════════ MOVIE INFO VIEW ══════════════════════════════════════ -->
+    <div id="view-movie-info" class="view">
+        <div id="movie-info-backdrop"></div>
+        <button id="movie-info-back" onclick="closeMovieInfo()"><img src="img/icons/back_arrow_white.svg" width="14" height="14" alt="" style="vertical-align:middle;margin-right:4px;">Back</button>
+        <div id="movie-info-scroll">
+            <div id="movie-info-hero">
+                <div id="movie-info-poster-wrap">
+                    <img id="movie-info-poster-img" src="img/thumbnail.png" alt="">
+                </div>
+                <div id="movie-info-meta">
+                    <div id="movie-info-title"></div>
+                    <div id="movie-info-filename"></div>
+                    <div id="movie-info-year"></div>
+                    <div id="movie-info-cast"></div>
+                    <div id="movie-info-actions">
+                        <button class="btn btn-primary" id="movie-info-play-btn"><img src="img/icons/play_black.svg" width="15" height="15" alt="" style="vertical-align:middle;margin-right:5px;">Play</button>
+                        <button class="btn btn-secondary" id="movie-info-fm-btn">Open in Files</button>
+                        <button class="yt-badge" id="movie-info-yt-btn">▶ YouTube</button>
+                        <button class="imdb-badge" id="movie-info-imdb-btn" style="display:none">IMDb</button>
+                    </div>
+                </div>
+            </div>
+            <div id="movie-info-loading"><div class="spinner"></div><span>Loading info…</span></div>
+            <div id="movie-info-files" style="display:none">
+                <div class="section-title" style="font-size:16px;margin:16px 20px 10px;">Files</div>
+                <div id="movie-info-episode-list"></div>
+            </div>
+        </div>
+    </div>
+
     <!-- ═══════════════ FOLDER VIEW ════════════════════════════════════════ -->
     <div id="view-folder" class="view">
         <div id="folder-nav-bar">
@@ -872,6 +1009,16 @@ html, body {
             <button id="player-back" onclick="closePlayer()"><img src="img/icons/back_arrow_white.svg" width="14" height="14" alt="" style="vertical-align:middle;margin-right:4px;">Back</button>
             <video id="main-video" preload="metadata"></video>
 
+            <!-- Resume-from-last-position popup -->
+            <div id="resume-popup">
+                <div id="resume-popup-title">Resume playback?</div>
+                <div id="resume-popup-sub"></div>
+                <div id="resume-popup-btns">
+                    <button class="resume-btn" id="resume-btn-continue">Resume</button>
+                    <button class="resume-btn" id="resume-btn-restart">Start over</button>
+                </div>
+            </div>
+
             <!-- Auto-play countdown -->
             <div id="next-countdown">
                 <div id="next-countdown-text">Next episode in <span id="countdown-num">5</span>s&hellip;</div>
@@ -889,7 +1036,7 @@ html, body {
                 <div class="ctx-divider"></div>
                 <div class="ctx-item" id="ctx-repeat"><i class="ctx-icon">↺</i>Repeat: Off</div>
                 <div class="ctx-divider"></div>
-                <div class="ctx-item" id="ctx-props"><i class="ctx-icon">ℹ</i>Video Properties</div>
+                <div class="ctx-item" id="ctx-props">Video Properties</div>
                 <!-- <div class="ctx-item" id="ctx-stats"><i class="ctx-icon">⧉</i>Streaming Stats</div> -->
             </div>
 
@@ -966,14 +1113,16 @@ var controlsTimer  = null;
 
 // Library pagination
 var PAGE_SIZE   = 24;
-var moviesData  = [];
-var showsData   = [];
-var shortsData  = [];
-var animeData   = [];
-var moviesShown = 0;
-var showsShown  = 0;
-var shortsShown = 0;
-var animeShown  = 0;
+var moviesData      = [];
+var collectionsData = [];
+var showsData       = [];
+var shortsData      = [];
+var animeData       = [];
+var moviesShown      = 0;
+var collectionsShown = 0;
+var showsShown       = 0;
+var shortsShown      = 0;
+var animeShown       = 0;
 
 // Auto-play between episodes
 var autoplayEnabled = localStorage.getItem('movie_autoplay') !== '0'; // on by default
@@ -982,10 +1131,228 @@ var countdownTimer  = null;
 // Single-repeat
 var repeatSingle = false;
 
+// Movie info panel
+var currentMovieAlbum  = null;
+var movieInfoEpisodes  = [];
+var currentMovieImdbTitle = null; // IMDB title once fetched; null until then
+
+// Player return-destination and watch-position tracking
+var playerReturnView  = 'library';
+var pendingResumePos  = 0;
+var watchSaveInterval = null;
+
 // Folder browse state
 var folderViewPath   = '/';
 var folderViewVideos = [];
 
+// ─── Movie info panel ─────────────────────────────────────────────────────────
+function openMovieInfo(album) {
+    currentMovieAlbum     = album;
+    currentAlbum          = album;
+    movieInfoEpisodes     = [];
+    currentMovieImdbTitle = null;
+
+    // Seed with local data immediately
+    $('#movie-info-title').text(album.name);
+    $('#movie-info-filename').hide().text('');
+    $('#movie-info-year').text('');
+    $('#movie-info-cast').text('');
+    $('#movie-info-imdb-btn').hide();
+    $('#movie-info-files').hide();
+    $('#movie-info-loading').addClass('active');
+
+    var localThumb = album.thumbnail
+        ? 'data:image/jpeg;base64,' + album.thumbnail
+        : 'img/thumbnail.png';
+    $('#movie-info-poster-img').attr('src', localThumb);
+    $('#movie-info-backdrop').css('background-image', 'url(' + localThumb + ')');
+
+    // Bind Play button
+    $('#movie-info-play-btn').off('click').on('click', function () {
+        if (movieInfoEpisodes.length > 0) {
+            currentAlbum = album; currentSeason = null;
+            currentEpisodes = movieInfoEpisodes;
+            startPlayback(0);
+        }
+    });
+
+    // Bind Open in Files — opens file manager at the movie's location
+    $('#movie-info-fm-btn').off('click').on('click', function () {
+        if (!currentMovieAlbum) { return; }
+        if (currentMovieAlbum._singleFile) {
+            var fp    = currentMovieAlbum._singleFile;
+            var dir   = fp.substring(0, fp.lastIndexOf('/'));
+            var fname = fp.split('/').pop();
+            ao_module_openPath(dir, fname);
+        } else {
+            ao_module_openPath(currentMovieAlbum.folderpath);
+        }
+    });
+
+    // Bind YouTube search — uses IMDB title once available, raw name otherwise
+    $('#movie-info-yt-btn').off('click').on('click', function () {
+        var term = currentMovieImdbTitle || (currentMovieAlbum ? currentMovieAlbum.name : '');
+        window.open('https://www.youtube.com/results?search_query=' + encodeURIComponent(term + ' trailer'), '_blank');
+    });
+
+    showView('movie-info');
+
+    // Fetch IMDB metadata
+    ao_module_agirun(SCRIPT_GET_MOVIE_INFO, { movie: album.name }, function (data) {
+        $('#movie-info-loading').removeClass('active');
+        if (data && !data.error) { applyMovieInfo(data); }
+    }, function () {
+        $('#movie-info-loading').removeClass('active');
+    });
+
+    // Build file list
+    if (album._singleFile) {
+        var ext = '.' + album._singleFile.split('.').pop().toLowerCase();
+        movieInfoEpisodes = [{ name: album.name, filepath: album._singleFile, ext: ext, index: 0 }];
+        // Single file — file list not needed (Play button is enough)
+    } else {
+        ao_module_agirun(SCRIPT_GET_EPISODES, { folder: album.folderpath }, function (data) {
+            if (data && !data.error && data.length > 0) {
+                movieInfoEpisodes = data;
+                if (data.length > 1) { renderMovieFileList(data); }
+            }
+        });
+    }
+}
+
+function applyMovieInfo(info) {
+    var imdbTitle = info['#TITLE']      || '';
+    var year      = info['#YEAR']       ? String(info['#YEAR']) : '';
+    var actors    = info['#ACTORS']    || '';
+    var imdbUrl   = info['#IMDB_URL']  || '';
+    var poster    = info['#IMG_POSTER'] || '';
+
+    // Replace the displayed title with the canonical IMDB title and show the
+    // original filename as a small italic subtitle underneath.
+    if (imdbTitle) {
+        currentMovieImdbTitle = imdbTitle;
+        $('#movie-info-title').text(imdbTitle);
+        if (currentMovieAlbum && currentMovieAlbum.name !== imdbTitle) {
+            $('#movie-info-filename').text(currentMovieAlbum.name).show();
+        }
+    }
+
+    if (year)   { $('#movie-info-year').text(year); }
+    if (actors) {
+        $('#movie-info-cast').html(
+            '<span style="color:var(--text-sub);font-weight:600">Cast</span> ' + escapeHtml(actors)
+        );
+    }
+
+    if (poster) {
+        var img = document.getElementById('movie-info-poster-img');
+        img.onerror = function () {
+            img.src = (currentMovieAlbum && currentMovieAlbum.thumbnail)
+                ? 'data:image/jpeg;base64,' + currentMovieAlbum.thumbnail
+                : 'img/thumbnail.png';
+            img.onerror = null;
+        };
+        img.onload = function () {
+            $('#movie-info-backdrop').css('background-image', 'url(' + poster + ')');
+            img.onload = null;
+        };
+        img.src = poster;
+    }
+
+    if (imdbUrl) {
+        $('#movie-info-imdb-btn').show().off('click').on('click', function () {
+            window.open(imdbUrl, '_blank');
+        });
+    }
+}
+
+function renderMovieFileList(episodes) {
+    var $list = $('#movie-info-episode-list').empty();
+    episodes.forEach(function (ep, i) {
+        var row = $('<div class="episode-item" tabindex="0" role="button">'
+            + '<div class="ep-thumb"><div class="ep-thumb-placeholder"><img src="img/icons/play_white.svg" alt=""></div></div>'
+            + '<div class="ep-info">'
+            +   '<div class="ep-name">' + escapeHtml(ep.name) + '</div>'
+            +   '<div class="ep-path">' + escapeHtml((ep.ext || '').replace('.', '').toUpperCase()) + '</div>'
+            + '</div>'
+            + '<div class="ep-play-icon"><img src="img/icons/play_white.svg" alt=""></div>'
+            + '</div>');
+        row.data('idx', i);
+        row.on('click', function () {
+            currentAlbum = currentMovieAlbum; currentSeason = null;
+            currentEpisodes = movieInfoEpisodes;
+            startPlayback($(this).data('idx'));
+        });
+        row.on('keydown', function (e) {
+            if (e.key === 'Enter' || e.key === ' ') {
+                e.preventDefault();
+                currentAlbum = currentMovieAlbum; currentSeason = null;
+                currentEpisodes = movieInfoEpisodes;
+                startPlayback($(this).data('idx'));
+            }
+        });
+        $list.append(row);
+        // Lazy thumbnail
+        (function (epObj, rowEl) {
+            ao_module_agirun(SCRIPT_GET_THUMBNAIL, { file: epObj.filepath }, function (data) {
+                if (data && !data.error && data.length > 20) {
+                    rowEl.find('.ep-thumb-placeholder')
+                        .replaceWith('<img src="data:image/jpeg;base64,' + data + '" alt="">');
+                }
+            });
+        })(ep, row);
+    });
+    $('#movie-info-files').show();
+}
+
+function closeMovieInfo() {
+    currentMovieAlbum = null;
+    movieInfoEpisodes = [];
+    showView('library');
+}
+
+// ─── Watch position (resume) ──────────────────────────────────────────────────
+function saveWatchPosition() {
+    var vid = document.getElementById('main-video');
+    if (playingIndex < 0 || !currentEpisodes || !vid.duration || vid.duration < 3600) { return; }
+    var ep = currentEpisodes[playingIndex];
+    if (!ep || vid.currentTime < 10) { return; }
+    ao_module_agirun(SCRIPT_SET_WATCHTIME, {
+        filepath: ep.filepath,
+        position: Math.floor(vid.currentTime),
+        duration: Math.floor(vid.duration)
+    }, function () {}, function () {});
+}
+
+function clearWatchPosition() {
+    if (playingIndex < 0 || !currentEpisodes) { return; }
+    var ep = currentEpisodes[playingIndex];
+    if (!ep) { return; }
+    ao_module_agirun(SCRIPT_SET_WATCHTIME, { filepath: ep.filepath, position: 0, duration: 0 },
+        function () {}, function () {});
+}
+
+function showResumePopup(savedPos, duration) {
+    var vid = document.getElementById('main-video');
+    vid.pause();
+    pendingResumePos = savedPos;
+    $('#resume-popup-sub').text(
+        'Last position: ' + formatTime(savedPos) + ' of ' + formatTime(duration)
+    );
+    $('#resume-popup').addClass('active');
+    showControls();
+
+    $('#resume-btn-continue').off('click').on('click', function () {
+        vid.currentTime = pendingResumePos;
+        vid.play();
+        $('#resume-popup').removeClass('active');
+    });
+    $('#resume-btn-restart').off('click').on('click', function () {
+        vid.play();
+        $('#resume-popup').removeClass('active');
+    });
+}
+
 // ─── Tab switching ────────────────────────────────────────────────────────────
 function switchTab(tab) {
     if (tab === 'folder') {
@@ -1179,53 +1546,42 @@ function loadLibrary() {
 
 // ─── Render library grid ──────────────────────────────────────────────────────
 function renderLibrary(albums) {
-    // Movies: non-short, non-anime, single-file  |  Shows: multi-episode (non-anime)  |  Shorts: type=short  |  Anime: type=anime
-    moviesData  = albums.filter(function (a) { return a.type !== 'series' && a.type !== 'short' && a.type !== 'anime' && a.episodeCount === 1; });
-    showsData   = albums.filter(function (a) { return a.type === 'series'; });
-    shortsData  = albums.filter(function (a) { return a.type === 'short'; });
-    animeData   = albums.filter(function (a) { return a.type === 'anime'; });
-    moviesShown = 0;
-    showsShown  = 0;
-    shortsShown = 0;
-    animeShown  = 0;
+    moviesData      = albums.filter(function (a) { return a.type === 'movie'; });
+    collectionsData = albums.filter(function (a) { return a.type === 'collection'; });
+    showsData       = albums.filter(function (a) { return a.type === 'series'; });
+    animeData       = albums.filter(function (a) { return a.type === 'anime'; });
+    shortsData      = albums.filter(function (a) { return a.type === 'short'; });
+    moviesShown      = 0;
+    collectionsShown = 0;
+    showsShown       = 0;
+    animeShown       = 0;
+    shortsShown      = 0;
     $('#movies-grid').empty();
+    $('#collections-grid').empty();
     $('#series-grid').empty();
     $('#shorts-grid').empty();
     $('#anime-grid').empty();
 
-    if (moviesData.length === 0 && showsData.length === 0 && shortsData.length === 0 && animeData.length === 0) {
+    if (!moviesData.length && !collectionsData.length && !showsData.length && !animeData.length && !shortsData.length) {
         $('#no-content').show();
         return;
     }
     $('#no-content').hide();
 
-    if (moviesData.length > 0) {
-        $('#movies-section').show();
-        loadMoreSection('movies');
-    } else {
-        $('#movies-section').hide();
-    }
+    if (moviesData.length > 0) { $('#movies-section').show(); loadMoreSection('movies'); }
+    else { $('#movies-section').hide(); }
 
-    if (showsData.length > 0) {
-        $('#series-section').show();
-        loadMoreSection('shows');
-    } else {
-        $('#series-section').hide();
-    }
+    if (collectionsData.length > 0) { $('#collections-section').show(); loadMoreSection('collections'); }
+    else { $('#collections-section').hide(); }
 
-    if (animeData.length > 0) {
-        $('#anime-section').show();
-        loadMoreSection('anime');
-    } else {
-        $('#anime-section').hide();
-    }
+    if (showsData.length > 0) { $('#series-section').show(); loadMoreSection('shows'); }
+    else { $('#series-section').hide(); }
 
-    if (shortsData.length > 0) {
-        $('#shorts-section').show();
-        loadMoreSection('shorts');
-    } else {
-        $('#shorts-section').hide();
-    }
+    if (animeData.length > 0) { $('#anime-section').show(); loadMoreSection('anime'); }
+    else { $('#anime-section').hide(); }
+
+    if (shortsData.length > 0) { $('#shorts-section').show(); loadMoreSection('shorts'); }
+    else { $('#shorts-section').hide(); }
 }
 
 function loadMoreSection(which) {
@@ -1248,6 +1604,15 @@ function loadMoreSection(which) {
         for (i = start; i < end; i++) { $grid.append(buildCard(data[i], i)); }
         showsShown = end;
         $btn.toggle(showsShown < data.length);
+    } else if (which === 'collections') {
+        data  = collectionsData;
+        $grid = $('#collections-grid');
+        $btn  = $('#collections-load-more');
+        start = collectionsShown;
+        end   = Math.min(start + PAGE_SIZE, data.length);
+        for (i = start; i < end; i++) { $grid.append(buildCard(data[i], i)); }
+        collectionsShown = end;
+        $btn.toggle(collectionsShown < data.length);
     } else if (which === 'anime') {
         data  = animeData;
         $grid = $('#anime-grid');
@@ -1283,7 +1648,9 @@ function buildCard(album, idx) {
         ? album.episodeCount + ' ep'
         : album.type === 'short'
             ? (ext || 'Short')
-            : album.episodeCount + (album.episodeCount > 1 ? ' parts' : ' movie');
+            : album.type === 'collection'
+                ? album.episodeCount + (album.episodeCount > 1 ? ' videos' : ' video')
+                : album.episodeCount + (album.episodeCount > 1 ? ' parts' : ' movie');
 
     var card = $('<div class="album-card" tabindex="0" role="button" aria-label="' + escapeAttr(album.name) + '">'
         + thumb
@@ -1301,6 +1668,8 @@ function buildCard(album, idx) {
             currentEpisodes = [{ name: album.name, filepath: album._singleFile,
                 ext: album._singleFile.split('.').pop().toLowerCase(), index: 0 }];
             startPlayback(0);
+        } else if (album.type === 'movie') {
+            openMovieInfo(album);
         } else { openDetail(album); }
     });
     card.on('keydown', function (e) {
@@ -1311,6 +1680,8 @@ function buildCard(album, idx) {
                 currentEpisodes = [{ name: album.name, filepath: album._singleFile,
                     ext: album._singleFile.split('.').pop().toLowerCase(), index: 0 }];
                 startPlayback(0);
+            } else if (album.type === 'movie') {
+                openMovieInfo(album);
             } else { openDetail(album); }
         }
     });
@@ -1352,6 +1723,8 @@ function openDetail(album) {
     $('#detail-title').text(album.name);
     var sub = (album.type === 'series' || album.type === 'anime')
         ? album.seasons.length + ' season' + (album.seasons.length !== 1 ? 's' : '') + ' · ' + album.episodeCount + ' episodes'
+        : album.type === 'collection'
+        ? album.episodeCount + (album.episodeCount > 1 ? ' videos' : ' video')
         : album.episodeCount + (album.episodeCount > 1 ? ' parts' : ' movie');
     $('#detail-subtitle').text(sub);
 
@@ -1448,18 +1821,16 @@ function isWebPlayable(ext) {
 }
 function startPlayback(index) {
     cancelCountdown();
+    $('#resume-popup').removeClass('active');
     if (!currentEpisodes || currentEpisodes.length === 0) { return; }
     playingIndex = index;
     var ep = currentEpisodes[index];
 
-    // Choose endpoint based on file extension
     var ext = ep.ext ? ep.ext.toLowerCase().replace(/^\./, '') : '';
-    var src = '';
-    if (isWebPlayable(ext)) {
-        src = MEDIA_API + '?file=' + encodeURIComponent(ep.filepath);
-    } else {
-        src = TRANSCODE_API + '?file=' + encodeURIComponent(ep.filepath);
-    }
+    var src = isWebPlayable(ext)
+        ? MEDIA_API   + '?file=' + encodeURIComponent(ep.filepath)
+        : TRANSCODE_API + '?file=' + encodeURIComponent(ep.filepath);
+
     var vid = document.getElementById('main-video');
     vid.src = src;
     vid.play();
@@ -1467,21 +1838,37 @@ function startPlayback(index) {
     $('#now-playing-title').text(ep.name);
     ao_module_setWindowTitle('Movie – ' + ep.name);
 
-    // Build sidebar list
     renderSidebar(currentEpisodes, index);
 
-    // Hide sidebar by default when there is only one video — nothing useful to show
     if (currentEpisodes.length <= 1) {
         $('#playlist-sidebar').addClass('collapsed');
     } else {
         $('#playlist-sidebar').removeClass('collapsed');
     }
 
-    // Highlight in episode list
     highlightPlayingEpisode(index);
 
+    // Remember where we came from so closePlayer() can return there
+    var curView = $('.view.active').attr('id');
+    if (curView && curView !== 'view-player') {
+        playerReturnView = curView.replace('view-', '');
+    }
+
     showView('player');
     showControls();
+
+    // After metadata loads, offer to resume if the video is >1 hr and has a saved position
+    (function (epFilepath) {
+        $(vid).off('loadedmetadata.resume').one('loadedmetadata.resume', function () {
+            if (vid.duration > 3600) {
+                ao_module_agirun(SCRIPT_GET_WATCHTIME, { filepath: epFilepath }, function (data) {
+                    if (data && !data.error && data.position > 30 && data.position < vid.duration * 0.95) {
+                        showResumePopup(data.position, vid.duration);
+                    }
+                });
+            }
+        });
+    })(ep.filepath);
 }
 
 function renderSidebar(episodes, playing) {
@@ -1527,15 +1914,16 @@ function highlightPlayingEpisode(idx) {
 }
 
 function closePlayer() {
+    saveWatchPosition();
     cancelCountdown();
+    if (watchSaveInterval) { clearInterval(watchSaveInterval); watchSaveInterval = null; }
     var vid = document.getElementById('main-video');
     vid.pause();
     vid.src = '';
-    var returnTo = (currentAlbum && (currentAlbum.type === 'series' || currentAlbum.type === 'anime'))
+    $('#resume-popup').removeClass('active');
+    var returnTo = (currentAlbum && (currentAlbum.type === 'series' || currentAlbum.type === 'anime' || currentAlbum.type === 'collection'))
         ? 'detail'
-        : (currentAlbum && currentAlbum.type === 'folder')
-        ? 'folder'
-        : 'library';
+        : playerReturnView;
     showView(returnTo);
 }
 
@@ -1548,14 +1936,18 @@ function showLibrary() {
 function showView(name) {
     $('.view').removeClass('active');
     $('#view-' + name).addClass('active');
-    if (name === 'folder') {
+    if (name === 'library' || name === 'detail') {
+        $('.mode-tab').removeClass('active');
+        $('#tab-library').addClass('active');
+        $('#search-wrap').show();
+    } else if (name === 'folder') {
         $('.mode-tab').removeClass('active');
         $('#tab-folder').addClass('active');
         $('#search-wrap').hide();
-    } else if (name === 'library' || name === 'detail') {
+    } else if (name === 'movie-info') {
         $('.mode-tab').removeClass('active');
         $('#tab-library').addClass('active');
-        $('#search-wrap').show();
+        $('#search-wrap').hide();
     }
     // player view leaves tab state unchanged
 }
@@ -1608,9 +2000,22 @@ function initVideoControls() {
         $time.text(formatTime(vid.currentTime) + ' / ' + formatTime(vid.duration));
     });
 
-    $(vid).on('play',  function () { $('#play-icon').attr('src', 'img/icons/pause_white.svg'); });
-    $(vid).on('pause', function () { $('#play-icon').attr('src', 'img/icons/play_white.svg'); });
+    $(vid).on('play', function () {
+        $('#play-icon').attr('src', 'img/icons/pause_white.svg');
+        // Periodically save position for videos longer than 1 hr
+        if (watchSaveInterval) { clearInterval(watchSaveInterval); }
+        watchSaveInterval = setInterval(function () {
+            if (!vid.paused && vid.duration > 3600) { saveWatchPosition(); }
+        }, 30000);
+    });
+    $(vid).on('pause', function () {
+        $('#play-icon').attr('src', 'img/icons/play_white.svg');
+        if (watchSaveInterval) { clearInterval(watchSaveInterval); watchSaveInterval = null; }
+        if (vid.duration > 3600 && vid.currentTime > 30) { saveWatchPosition(); }
+    });
     $(vid).on('ended', function () {
+        clearWatchPosition(); // video finished naturally — remove resume point
+        if (watchSaveInterval) { clearInterval(watchSaveInterval); watchSaveInterval = null; }
         cancelCountdown();
         if (repeatSingle) {
             vid.currentTime = 0;
@@ -1647,6 +2052,7 @@ function initVideoControls() {
 }
 
 function togglePlay() {
+    $('#resume-popup').removeClass('active');
     var vid = document.getElementById('main-video');
     if (vid.paused) { vid.play(); } else { vid.pause(); }
 }

+ 3 - 3
src/web/NotepadA/backend/newfile.js

@@ -1,4 +1,4 @@
 requirelib("filelib");
-filelib.mkdir("user:/Document/NotepadA");
-filelib.writeFile("user:/Document/NotepadA/" + tmpid + ".tmp", "");
-sendJSONResp(JSON.stringify("user:/Document/NotepadA/" + tmpid+".tmp"));
+filelib.mkdir("user:/Document/Appdata/NotepadA");
+filelib.writeFile("user:/Document/Appdata/NotepadA/" + tmpid + ".tmp", "");
+sendJSONResp(JSON.stringify("user:/Document/Appdata/NotepadA/" + tmpid+".tmp"));