From 77e83dee31740d0f0fc8e04bf51bd1682723d8fd Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Thu, 13 Aug 2026 22:21:34 +1000 Subject: [PATCH 1/5] fix(macos): sign the worker with the app's entitlements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entitlements are per-Mach-O and the app bundle's set does not reach a child process, so vapourbox-worker was signed hardened with none of its own. That leaves library validation enabled, and the worker dlopens the downloaded, ad-hoc-signed libdvdread.dylib from deps//lib/ (dvd_reader.rs) — which fails with "mapping process and mapped file (non-platform) have different Team IDs". DVD extraction has therefore never worked in a signed release build: Developer ID signing landed in 8181288 (Feb) and DVD import in c4a5ec8 (Mar). Nothing caught it. Debug builds are ad-hoc signed, so they have no Team ID to mismatch and extraction works locally; codesign --verify --deep --strict passes and notarization passes either way; and title enumeration parses the IFO files in pure Rust, so a disc lists its titles correctly right up to the point of extracting one. Sign the worker with distribution.entitlements, and assert after signing that both vapourbox and vapourbox-worker carry disable-library-validation so the property is checked where its absence is visible rather than relying on someone testing DVD extraction from a notarized DMG. --- CLAUDE.md | 17 +++++++++++++++++ Scripts/package-macos.sh | 25 ++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 81306b9..1b46e8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1525,6 +1525,23 @@ The macOS app is signed with a **Developer ID Application** certificate and nota `package-macos.sh` also works locally: with a Developer ID cert in your keychain it signs, and `--notarize` uses a stored `notarytool` keychain profile (default name `VapourBox`). In CI there is no keychain profile, so it falls back to `NOTARY_APPLE_ID` / `NOTARY_PASSWORD` / `NOTARY_TEAM_ID` env vars. Use `--no-sign` for ad-hoc local test builds. +> **Every helper executable needs the entitlements too, not just the app.** +> Entitlements are per-Mach-O and the app bundle's set does **not** reach a +> child process, so `vapourbox-worker` must be signed with +> `--entitlements packaging/macos/distribution.entitlements` in its own right. +> Signed hardened without them it gets **library validation**, and the worker +> `dlopen`s the downloaded, **ad-hoc-signed** `libdvdread.dylib` from +> `deps//lib/` (`worker/src/dvd_reader.rs`) — which then fails with +> *"mapping process and mapped file (non-platform) have different Team IDs"* and +> DVD import is dead in the shipped app. It shipped that way through 0.9.12. +> +> Nothing else catches this: the debug build is **ad-hoc signed**, so it has no +> Team ID to mismatch and DVD extraction works fine locally; `codesign --verify +> --deep --strict` passes, and notarization passes. `package-macos.sh` now +> asserts after signing that both `vapourbox` and `vapourbox-worker` carry +> `disable-library-validation`, and fails the package if either doesn't. Add any +> future bundled helper to that loop. + > **A notarization `403` is not necessarily your account.** The notary service > returns *"HTTP status code: 403. Invalid or inaccessible developer team ID for > the provided Apple ID"* during Apple-side outages, which reads like a diff --git a/Scripts/package-macos.sh b/Scripts/package-macos.sh index b4ff06f..0eb1cb2 100755 --- a/Scripts/package-macos.sh +++ b/Scripts/package-macos.sh @@ -281,9 +281,19 @@ else done # 4. Sign helper executables + # + # The worker needs the SAME entitlements as the app, not just the hardened + # runtime. Entitlements are per-executable, and the worker is its own + # process — the app bundle's set does not reach it. Signed hardened with no + # entitlements it gets library validation, and it dlopens the downloaded, + # ad-hoc-signed libdvdread from deps/ (dvd_reader.rs), which then fails with + # "mapping process and mapped file (non-platform) have different Team IDs" + # and no DVD can be extracted. Debug builds never show this: they are + # ad-hoc signed, so there is no Team ID to mismatch. echo " Signing helper executables..." if [ -f "$CONTENTS/MacOS/vapourbox-worker" ]; then - codesign --force --sign "$IDENTITY" --options runtime --timestamp "$CONTENTS/MacOS/vapourbox-worker" + codesign --force --sign "$IDENTITY" --options runtime --timestamp \ + --entitlements "$ENTITLEMENTS" "$CONTENTS/MacOS/vapourbox-worker" fi # 5. Sign the main app bundle with entitlements @@ -293,6 +303,19 @@ else # 6. Verify echo " Verifying signature..." codesign --verify --deep --strict "$APP_BUNDLE" + + # Every Mach-O we launch as its own process must carry + # disable-library-validation, or it cannot dlopen the ad-hoc-signed + # libraries in the downloaded deps bundle (see step 4). + for exe in "$CONTENTS/MacOS/vapourbox" "$CONTENTS/MacOS/vapourbox-worker"; do + [ -f "$exe" ] || continue + if ! codesign -d --entitlements - --xml "$exe" 2>/dev/null \ + | grep -q "com.apple.security.cs.disable-library-validation"; then + echo "ERROR: $(basename "$exe") is signed without disable-library-validation" + echo " It will fail to dlopen the ad-hoc-signed deps libraries." + exit 1 + fi + done echo " Signature verified OK" fi From 7f7c1f5a44f4d6eb344f5c508a2a4612d47e5a7f Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Thu, 13 Aug 2026 22:21:38 +1000 Subject: [PATCH 2/5] chore: bump app version to 0.9.13 --- app/macos/Runner/Info.plist | 4 ++-- app/pubspec.yaml | 2 +- app/windows/runner/Runner.rc | 4 ++-- worker/Cargo.toml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/macos/Runner/Info.plist b/app/macos/Runner/Info.plist index f5dd725..451783a 100644 --- a/app/macos/Runner/Info.plist +++ b/app/macos/Runner/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.9.12 + 0.9.13 CFBundleVersion - 0.9.12 + 0.9.13 LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) NSHumanReadableCopyright diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 030fefe..92c2252 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -1,7 +1,7 @@ name: vapourbox description: "Video processing and cleanup powered by VapourSynth" publish_to: 'none' -version: 0.9.12+32 +version: 0.9.13+33 environment: sdk: ^3.6.2 diff --git a/app/windows/runner/Runner.rc b/app/windows/runner/Runner.rc index 08a0dad..393295e 100644 --- a/app/windows/runner/Runner.rc +++ b/app/windows/runner/Runner.rc @@ -73,8 +73,8 @@ IDI_APP_ICON ICON "resources\\app_icon.ico" #endif VS_VERSION_INFO VERSIONINFO - FILEVERSION 0,9,12,0 - PRODUCTVERSION 0,9,12,0 + FILEVERSION 0,9,13,0 + PRODUCTVERSION 0,9,13,0 FILEFLAGSMASK VS_FFI_FILEFLAGSMASK #ifdef _DEBUG FILEFLAGS VS_FF_DEBUG diff --git a/worker/Cargo.toml b/worker/Cargo.toml index 7047419..6fbc87d 100644 --- a/worker/Cargo.toml +++ b/worker/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vapourbox-worker" -version = "0.9.12" +version = "0.9.13" edition = "2021" description = "Video processing worker using VapourSynth" authors = ["Stuart Cameron"] From 85b0917f64dd5ce69ae397d6257cc0c6ec1d4973 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Thu, 13 Aug 2026 22:29:52 +1000 Subject: [PATCH 3/5] fix(app): match the About screen attributions to README.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The About dialog had drifted from README's Acknowledgments in both directions. Missing entirely: VIVTC, nnedi3, akarin, zsmooth, whisper.cpp and libdvdread — all bundled, all GPL/LGPL/MIT code we are obliged to attribute. Listed but no longer shipped: ffms2, which is absent from deps-expected-plugins.json on every platform (FFMS2 gave way to BestSource, and BestSource to the pipe source, which was removed 2026-08-07). Licence and copyright for each addition come from licenses/NOTICES.txt, except two that were checked upstream: VIVTC is LGPL 2.1 (from its own LICENSE file, not GPL as the sibling plugins are), and libdvdread is GPL 2.0-or-later (COPYING and the source headers in the 6.1.3 tarball the deps scripts build from). The list stays a superset of README's, which defers the complete inventory to NOTICES.txt — EEDI3, DFTTest, neo-f3kdb, CAS, Flutter and Python are bundled and keep their attribution. QTGMC and Hybrid get a plain-text credit rather than tiles: one is an algorithm shipped inside havsfunc, the other is inspiration, so neither has a licence of its own to badge. Also repoints fmtconv at gitlab.com, since the GitHub repo is the abandoned mirror the deps scripts no longer use. --- app/lib/views/about_dialog.dart | 61 +++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/app/lib/views/about_dialog.dart b/app/lib/views/about_dialog.dart index febd38a..697c6ba 100644 --- a/app/lib/views/about_dialog.dart +++ b/app/lib/views/about_dialog.dart @@ -166,12 +166,24 @@ class _AboutDialogState extends State { copyright: 'HolyWu', url: 'https://github.com/HomeOfVapourSynthEvolution/havsfunc', ), + _ComponentTile( + name: 'VIVTC', + license: 'LGPL 2.1', + copyright: 'Fredrik Mellbin', + url: 'https://github.com/vapoursynth/vivtc', + ), _ComponentTile( name: 'mvtools', license: 'GPL 2.0', copyright: 'Manao, Fizick, Pinterf, dubhater', url: 'https://github.com/dubhater/vapoursynth-mvtools', ), + _ComponentTile( + name: 'nnedi3', + license: 'GPL 2.0', + copyright: 'Kevin Stone (tritical); port by dubhater', + url: 'https://github.com/dubhater/vapoursynth-nnedi3', + ), _ComponentTile( name: 'znedi3', license: 'GPL 2.0', @@ -184,18 +196,24 @@ class _AboutDialogState extends State { copyright: 'tritical, HolyWu', url: 'https://github.com/HomeOfVapourSynthEvolution/VapourSynth-EEDI3', ), + _ComponentTile( + name: 'akarin', + license: 'LGPL 3.0', + copyright: 'The akarin plugin authors', + url: 'https://github.com/Jaded-Encoding-Thaumaturgy/akarin-vapoursynth-plugin', + ), + _ComponentTile( + name: 'zsmooth', + license: 'MIT', + copyright: 'Adrian Woracz', + url: 'https://github.com/adworacz/zsmooth', + ), _ComponentTile( name: 'FFmpeg', license: 'LGPL 2.1+', copyright: 'FFmpeg contributors', url: 'https://github.com/FFmpeg/FFmpeg', ), - _ComponentTile( - name: 'ffms2', - license: 'MIT', - copyright: 'FFMS contributors', - url: 'https://github.com/FFMS/ffms2', - ), _ComponentTile( name: 'DFTTest', license: 'GPL 3.0', @@ -218,7 +236,19 @@ class _AboutDialogState extends State { name: 'fmtconv', license: 'WTFPL', copyright: 'Firesledge (Laurent de Soras)', - url: 'https://github.com/EleonoreMizo/fmtconv', + url: 'https://gitlab.com/EleonoreMizo/fmtconv', + ), + _ComponentTile( + name: 'whisper.cpp', + license: 'MIT', + copyright: 'Georgi Gerganov', + url: 'https://github.com/ggerganov/whisper.cpp', + ), + _ComponentTile( + name: 'libdvdread', + license: 'GPL 2.0+', + copyright: 'VideoLAN and contributors', + url: 'https://code.videolan.org/videolan/libdvdread', ), _ComponentTile( name: 'Flutter', @@ -237,6 +267,23 @@ class _AboutDialogState extends State { ), ), + const SizedBox(height: 12), + + // Credits that are not bundled components: QTGMC is an algorithm + // (shipped as part of havsfunc above), Hybrid is inspiration only. + // Both are acknowledged in README.md, so neither gets a licence + // badge here. + Text( + 'QTGMC deinterlacing algorithm by Vit. ' + 'Inspired by Hybrid by Selur.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + ), + ), + const SizedBox(height: 16), // Buttons From 1aeecd473cad10a09639a824a1fa81554bdb392b Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Thu, 13 Aug 2026 23:20:12 +1000 Subject: [PATCH 4/5] fix(app): open a flat DVD rip as a DVD, not as loose VOB files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rip that keeps the wrapping VIDEO_TS directory was already detected, but one holding the VIDEO_TS *contents* directly — VIDEO_TS.IFO and VTS_01_*.VOB in a folder named after the disc, which is what several rippers produce and what you get by copying the files out by hand — fell through to the generic folder scan. Every VOB fragment was then queued as a separate video, with no titles, no chapters and no audio track choice. Measured across the five shapes a user can drop, only that one was wrong: plain folders, folder-containing-VIDEO_TS, and the VIDEO_TS directory itself all routed correctly before and after. findVideoTsParent becomes findDvdRoot and gains the flat case. It now also matches VIDEO_TS, the IFOs and the VOBs case-insensitively by listing the directory; the old File('$path/VIDEO_TS.IFO').exists() pair only worked because macOS and Windows fold case, so a mixed-case rip on Linux was already missed. The flat case demands an IFO *and* at least one VOB where the other two need only an IFO. A folder with a VIDEO_TS subdirectory is unambiguous, but a flat folder is ordinary until proven otherwise, and one stray VIDEO_TS.IFO must not divert a folder of holiday videos into the title picker. An IFO with no VOB is not extractable anyway. The worker needs the same shape or the app would route a flat rip to a process that rejects it: find_video_ts_dir now treats a folder holding a DVD IFO as the VIDEO_TS directory itself. Tests: 15 Dart cases over every folder shape, and on the Rust side both the directory lookup and a full title enumeration from a flat rip, which proves the reading path works end to end for the new shape rather than just the lookup. --- README.md | 4 +- app/lib/services/disc_detector.dart | 96 +++++++++++++---- app/lib/viewmodels/main_viewmodel.dart | 7 +- app/lib/views/drop_zone.dart | 2 +- app/test/dvd_folder_detection_test.dart | 138 ++++++++++++++++++++++++ worker/src/dvd_reader.rs | 121 +++++++++++++++++++++ 6 files changed, 345 insertions(+), 23 deletions(-) create mode 100644 app/test/dvd_folder_detection_test.dart diff --git a/README.md b/README.md index 3d831d1..011e2d4 100644 --- a/README.md +++ b/README.md @@ -118,10 +118,12 @@ Each filter leads with a plain-language summary and a **More** expander describi **From a disc:** insert it and click the disc icon in the toolbar, or **Open DVD** on the drop zone. VapourBox reads the disc structure and shows a title picker with duration, resolution, chapters and audio tracks. Select a title and **Add to Queue** — it's extracted to a temporary file, then analyzed and queued like any other video. -**From a folder:** drag in a folder containing `VIDEO_TS` (or the `VIDEO_TS` folder itself) and the title picker appears automatically. +**From a ripped folder:** drag in a folder containing `VIDEO_TS`, the `VIDEO_TS` folder itself, or a flat rip — the `VIDEO_TS` contents (`VIDEO_TS.IFO`, `VTS_01_1.VOB`, …) sitting directly in a folder named after the disc. The title picker appears automatically in all three cases. **A folder of loose files:** drag it in, or use **Open Folder** — the folder is scanned recursively and everything found is queued. +VapourBox decides between the two by looking for a DVD IFO: a folder is only treated as a disc if one is present, and a flat rip additionally needs at least one `.VOB`, so a stray `VIDEO_TS.IFO` beside ordinary videos won't divert the whole folder into the title picker. + **Encrypted discs.** Most commercial DVDs use CSS encryption. VapourBox reads discs via libdvdread, which can load libdvdcss at runtime to decrypt them, but **libdvdcss is not bundled** — install it separately if you need it. Unencrypted discs (home recordings, some independent releases) work without it. - **macOS:** `brew install libdvdcss` diff --git a/app/lib/services/disc_detector.dart b/app/lib/services/disc_detector.dart index 6dcabf2..c5b224f 100644 --- a/app/lib/services/disc_detector.dart +++ b/app/lib/services/disc_detector.dart @@ -109,29 +109,89 @@ class DiscDetector { return discs; } - /// Checks if a given path contains a VIDEO_TS directory (or IS a VIDEO_TS directory). - /// Returns the parent path (mount point) if found, or null. - static Future findVideoTsParent(String path) async { - // Check if path itself is named VIDEO_TS + /// Decides whether [path] is a ripped DVD, and if so returns the path to + /// hand the worker's `--dvd-info` / `--dvd-extract`. Returns null for an + /// ordinary folder, which the caller should scan for video files instead. + /// + /// Three shapes count as a DVD, in this order: + /// + /// 1. [path] **is** a `VIDEO_TS` directory holding an IFO — the disc root is + /// its parent. + /// 2. [path] **contains** a `VIDEO_TS` directory holding an IFO — the usual + /// mounted disc or a rip that kept the wrapping directory. + /// 3. [path] holds the VIDEO_TS *contents* directly (a "flat" rip, named + /// after the disc): an IFO **and** at least one `.VOB`. + /// + /// Case 3 demands a VOB where the first two do not, deliberately. A folder + /// with a `VIDEO_TS` subdirectory is unambiguous, but a flat folder is + /// ordinary until proven otherwise, and a stray IFO next to unrelated videos + /// must not hijack the whole folder into the DVD flow. An IFO with no VOB is + /// not extractable anyway. + static Future findDvdRoot(String path) async { + final dir = Directory(path); + if (!await dir.exists()) return null; + + // 1. The path itself is a VIDEO_TS directory. final dirName = path.replaceAll('\\', '/').split('/').last.toUpperCase(); - if (dirName == 'VIDEO_TS') { - final parent = Directory(path).parent.path; - if (await File('$path/VIDEO_TS.IFO').exists() || - await File('$path/video_ts.ifo').exists()) { - return parent; - } + if (dirName == 'VIDEO_TS' && await _hasDvdIfo(dir)) { + return dir.parent.path; } - // Check if path contains VIDEO_TS - final videoTsDir = Directory('$path/VIDEO_TS'); - if (await videoTsDir.exists()) { - // Verify it has IFO files - if (await File('$path/VIDEO_TS/VIDEO_TS.IFO').exists() || - await File('$path/VIDEO_TS/video_ts.ifo').exists()) { - return path; - } + // 2. A VIDEO_TS subdirectory (matched case-insensitively, since only + // Windows and macOS resolve the case for us). + final videoTs = await _findChildDirectory(dir, 'video_ts'); + if (videoTs != null && await _hasDvdIfo(videoTs)) { + return path; + } + + // 3. A flat rip: VIDEO_TS contents sitting directly in the folder. + if (await _hasDvdIfo(dir) && await _hasVob(dir)) { + return path; } return null; } + + /// Case-insensitive lookup of a child directory by [name] (already lowercase). + static Future _findChildDirectory( + Directory parent, String name) async { + try { + await for (final entity in parent.list(followLinks: false)) { + if (entity is Directory && + entity.path.replaceAll('\\', '/').split('/').last.toLowerCase() == + name) { + return entity; + } + } + } catch (_) { + // Unreadable directory — treat as not a DVD. + } + return null; + } + + /// Whether [dir] directly contains `VIDEO_TS.IFO` or a `VTS_nn_0.IFO`. + static Future _hasDvdIfo(Directory dir) => + _anyFile(dir, (name) => name == 'video_ts.ifo' || _vtsIfo.hasMatch(name)); + + /// Whether [dir] directly contains any `.VOB`. + static Future _hasVob(Directory dir) => + _anyFile(dir, (name) => name.endsWith('.vob')); + + static final RegExp _vtsIfo = RegExp(r'^vts_\d{2}_0\.ifo$'); + + /// Whether any file directly in [dir] has a lowercased name matching [test]. + static Future _anyFile( + Directory dir, bool Function(String name) test) async { + try { + await for (final entity in dir.list(followLinks: false)) { + if (entity is! File) continue; + final name = + entity.path.replaceAll('\\', '/').split('/').last.toLowerCase(); + if (test(name)) return true; + } + } catch (_) { + // Unreadable directory — treat as not a DVD. + } + return false; + } } diff --git a/app/lib/viewmodels/main_viewmodel.dart b/app/lib/viewmodels/main_viewmodel.dart index ba906a7..0470ab8 100644 --- a/app/lib/viewmodels/main_viewmodel.dart +++ b/app/lib/viewmodels/main_viewmodel.dart @@ -1523,10 +1523,11 @@ class MainViewModel extends ChangeNotifier { } } - /// Add a folder: if it's a VIDEO_TS folder, treat as DVD; otherwise scan for videos. + /// Add a folder: if it's a ripped DVD, treat as DVD; otherwise scan for videos. Future addFolder(String folderPath) async { - // Check if this is a DVD folder (contains VIDEO_TS) - final dvdMountPoint = await DiscDetector.findVideoTsParent(folderPath); + // Check if this is a ripped DVD (a VIDEO_TS directory, a folder containing + // one, or a flat rip holding the VIDEO_TS contents directly). + final dvdMountPoint = await DiscDetector.findDvdRoot(folderPath); if (dvdMountPoint != null) { // Route to DVD enumeration flow — caller should show title picker // We throw a special exception that the UI can catch to show the DVD picker diff --git a/app/lib/views/drop_zone.dart b/app/lib/views/drop_zone.dart index 181a16c..3bcfdf5 100644 --- a/app/lib/views/drop_zone.dart +++ b/app/lib/views/drop_zone.dart @@ -113,7 +113,7 @@ class _DropZoneState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 48), child: Text( - 'Supported: Video files, folders of videos, DVD discs, and VIDEO_TS folders', + 'Supported: Video files, folders of videos, DVD discs, and ripped DVD folders', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurface.withValues(alpha: 0.4), diff --git a/app/test/dvd_folder_detection_test.dart b/app/test/dvd_folder_detection_test.dart new file mode 100644 index 0000000..4fc856c --- /dev/null +++ b/app/test/dvd_folder_detection_test.dart @@ -0,0 +1,138 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:vapourbox/services/disc_detector.dart'; + +/// What `DiscDetector.findDvdRoot` must decide for each folder shape a user can +/// drop on the app. A wrong answer here is silent either way: a ripped DVD +/// misread as a plain folder queues dozens of raw VOB fragments, and a plain +/// folder misread as a DVD sends the user to a title picker for a disc that +/// isn't there. +void main() { + late Directory sandbox; + + setUp(() async { + sandbox = await Directory.systemTemp.createTemp('vb_dvd_folder_test_'); + }); + + tearDown(() async { + if (await sandbox.exists()) { + await sandbox.delete(recursive: true); + } + }); + + /// Creates [relativePaths] as empty files under a folder named [name]. + Future makeFolder(String name, List relativePaths) async { + final dir = Directory('${sandbox.path}/$name'); + await dir.create(recursive: true); + for (final rel in relativePaths) { + final file = File('${dir.path}/$rel'); + await file.parent.create(recursive: true); + await file.writeAsBytes(const []); + } + return dir; + } + + group('ordinary folders are not DVDs', () { + test('a folder of video files', () async { + final dir = await makeFolder('holiday', ['a.mp4', 'b.mkv', 'c.avi']); + expect(await DiscDetector.findDvdRoot(dir.path), isNull); + }); + + test('an empty folder', () async { + final dir = await makeFolder('empty', []); + expect(await DiscDetector.findDvdRoot(dir.path), isNull); + }); + + test('a folder of loose VOBs with no IFO — not extractable as a disc', + () async { + final dir = await makeFolder('vobs', ['clip1.vob', 'clip2.vob']); + expect(await DiscDetector.findDvdRoot(dir.path), isNull); + }); + + test('a stray IFO among unrelated videos does not hijack the folder', + () async { + final dir = await makeFolder('mixed', ['VIDEO_TS.IFO', 'a.mp4', 'b.mkv']); + expect(await DiscDetector.findDvdRoot(dir.path), isNull); + }); + + test('a path that does not exist', () async { + expect(await DiscDetector.findDvdRoot('${sandbox.path}/nope'), isNull); + }); + }); + + group('ripped DVDs are DVDs', () { + test('folder containing VIDEO_TS/ returns the folder itself', () async { + final dir = await makeFolder('MyDisc', [ + 'VIDEO_TS/VIDEO_TS.IFO', + 'VIDEO_TS/VTS_01_0.IFO', + 'VIDEO_TS/VTS_01_1.VOB', + ]); + expect(await DiscDetector.findDvdRoot(dir.path), dir.path); + }); + + test('the VIDEO_TS directory itself returns its parent', () async { + final dir = await makeFolder('MyDisc2', ['VIDEO_TS/VIDEO_TS.IFO']); + final videoTs = '${dir.path}/VIDEO_TS'; + expect(await DiscDetector.findDvdRoot(videoTs), dir.path); + }); + + test('lowercase video_ts is recognised', () async { + final dir = await makeFolder('MyDisc3', ['video_ts/video_ts.ifo']); + expect(await DiscDetector.findDvdRoot(dir.path), dir.path); + }); + + test('a flat rip (VIDEO_TS contents, no wrapping directory)', () async { + final dir = await makeFolder('Wedding 1998', [ + 'VIDEO_TS.IFO', + 'VIDEO_TS.BUP', + 'VTS_01_0.IFO', + 'VTS_01_1.VOB', + 'VTS_01_2.VOB', + ]); + expect(await DiscDetector.findDvdRoot(dir.path), dir.path); + }); + + test('a flat rip with only a VTS IFO (no VIDEO_TS.IFO)', () async { + final dir = await makeFolder('Concert', [ + 'VTS_01_0.IFO', + 'VTS_01_1.VOB', + ]); + expect(await DiscDetector.findDvdRoot(dir.path), dir.path); + }); + + test('a flat rip in lowercase', () async { + final dir = await makeFolder('lower', [ + 'video_ts.ifo', + 'vts_01_1.vob', + ]); + expect(await DiscDetector.findDvdRoot(dir.path), dir.path); + }); + + test('a VIDEO_TS subdirectory wins over loose videos beside it', () async { + final dir = await makeFolder('BothShapes', [ + 'VIDEO_TS/VIDEO_TS.IFO', + 'VIDEO_TS/VTS_01_1.VOB', + 'extra.mp4', + ]); + expect(await DiscDetector.findDvdRoot(dir.path), dir.path); + }); + }); + + group('edge cases', () { + test('a VIDEO_TS directory with no IFO is not a DVD', () async { + final dir = await makeFolder('Hollow', ['VIDEO_TS/readme.txt']); + expect(await DiscDetector.findDvdRoot(dir.path), isNull); + }); + + test('a folder named VIDEO_TS but empty is not a DVD', () async { + final dir = await makeFolder('VIDEO_TS', []); + expect(await DiscDetector.findDvdRoot(dir.path), isNull); + }); + + test('VTS numbering must be two digits followed by _0', () async { + final dir = await makeFolder('NotIfo', ['VTS_1_1.IFO', 'VTS_01_1.VOB']); + expect(await DiscDetector.findDvdRoot(dir.path), isNull); + }); + }); +} diff --git a/worker/src/dvd_reader.rs b/worker/src/dvd_reader.rs index 4815cf4..692592f 100644 --- a/worker/src/dvd_reader.rs +++ b/worker/src/dvd_reader.rs @@ -256,9 +256,44 @@ fn find_video_ts_dir(path: &str) -> Result { return Ok(video_ts_lower); } + // A "flat" rip: the folder holds the VIDEO_TS *contents* without the + // wrapping directory, and is named after the disc rather than VIDEO_TS + // (what several rippers produce, and what you get by copying the files out + // of a VIDEO_TS by hand). The IFO is the marker — the folder itself then + // plays the role of the VIDEO_TS directory. + if dir_has_dvd_ifo(p) { + return Ok(p.to_path_buf()); + } + bail!("VIDEO_TS directory not found at {:?}", path) } +/// Whether a directory directly contains a DVD IFO (`VIDEO_TS.IFO` or a +/// `VTS_nn_0.IFO`), matched case-insensitively because rips vary and only +/// Windows/macOS filesystems are forgiving about it. +pub fn dir_has_dvd_ifo(dir: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(dir) else { + return false; + }; + + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_ascii_lowercase(); + if name == "video_ts.ifo" { + return true; + } + // VTS_01_0.IFO … VTS_99_0.IFO + if let Some(rest) = name.strip_prefix("vts_") { + if let Some(num) = rest.strip_suffix("_0.ifo") { + if num.len() == 2 && num.chars().all(|c| c.is_ascii_digit()) { + return true; + } + } + } + } + + false +} + /// Find an IFO file, handling case sensitivity. fn find_ifo_file(video_ts_dir: &Path, filename: &str) -> Result { // Try exact case first @@ -901,6 +936,70 @@ pub fn extract_title( mod tests { use super::*; + /// Creates the named empty files in a fresh temp dir and returns it. + fn dir_with(files: &[&str]) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + for rel in files { + let path = dir.path().join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"").unwrap(); + } + dir + } + + #[test] + fn test_find_video_ts_dir_nested() { + let dir = dir_with(&["VIDEO_TS/VIDEO_TS.IFO", "VIDEO_TS/VTS_01_1.VOB"]); + let found = find_video_ts_dir(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(found, dir.path().join("VIDEO_TS")); + } + + #[test] + fn test_find_video_ts_dir_is_video_ts() { + let dir = dir_with(&["VIDEO_TS/VIDEO_TS.IFO"]); + let video_ts = dir.path().join("VIDEO_TS"); + let found = find_video_ts_dir(video_ts.to_str().unwrap()).unwrap(); + assert_eq!(found, video_ts); + } + + /// A flat rip — the VIDEO_TS contents in a folder named after the disc. + /// The folder itself stands in for the VIDEO_TS directory; without this the + /// worker bails and the folder cannot be opened as a DVD at all. + #[test] + fn test_find_video_ts_dir_flat_rip() { + let dir = dir_with(&["VIDEO_TS.IFO", "VTS_01_0.IFO", "VTS_01_1.VOB"]); + let found = find_video_ts_dir(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(found, dir.path()); + } + + #[test] + fn test_find_video_ts_dir_flat_rip_vts_ifo_only() { + let dir = dir_with(&["vts_01_0.ifo", "vts_01_1.vob"]); + let found = find_video_ts_dir(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(found, dir.path()); + } + + #[test] + fn test_find_video_ts_dir_rejects_plain_folder() { + let dir = dir_with(&["a.mp4", "b.mkv"]); + assert!(find_video_ts_dir(dir.path().to_str().unwrap()).is_err()); + } + + #[test] + fn test_dir_has_dvd_ifo() { + assert!(dir_has_dvd_ifo(dir_with(&["VIDEO_TS.IFO"]).path())); + assert!(dir_has_dvd_ifo(dir_with(&["video_ts.ifo"]).path())); + assert!(dir_has_dvd_ifo(dir_with(&["VTS_01_0.IFO"]).path())); + assert!(dir_has_dvd_ifo(dir_with(&["VTS_99_0.IFO"]).path())); + + // Not IFOs that name a title set root. + assert!(!dir_has_dvd_ifo(dir_with(&["VTS_1_0.IFO"]).path())); + assert!(!dir_has_dvd_ifo(dir_with(&["VTS_01_1.VOB"]).path())); + assert!(!dir_has_dvd_ifo(dir_with(&["notes.ifo"]).path())); + assert!(!dir_has_dvd_ifo(dir_with(&[]).path())); + assert!(!dir_has_dvd_ifo(std::path::Path::new("/nonexistent/xyz"))); + } + #[test] fn test_bcd_to_u8() { assert_eq!(bcd_to_u8(0x00), 0); @@ -1264,4 +1363,26 @@ mod tests { assert_eq!(short.width, 720); assert_eq!(short.audio_tracks.len(), 2); } + + /// The same enumeration, but from a flat rip: the IFOs sit directly in a + /// folder named after the disc, with no VIDEO_TS directory. Proves the whole + /// title-reading path works for that shape, not just the directory lookup. + #[test] + fn test_enumerate_titles_from_flat_rip_folder() { + let tmp = tempfile::tempdir().unwrap(); + let disc = tmp.path().join("Wedding 1998"); + std::fs::create_dir(&disc).unwrap(); + + std::fs::write(disc.join("VIDEO_TS.IFO"), build_test_vmg_ifo()).unwrap(); + std::fs::write(disc.join("VTS_01_0.IFO"), build_test_vts_ifo()).unwrap(); + std::fs::write(disc.join("VTS_01_1.VOB"), b"").unwrap(); + + let info = enumerate_titles(disc.to_str().unwrap()).unwrap(); + + // The folder name is the disc name here, so it becomes the volume label. + assert_eq!(info.volume_label, "Wedding 1998"); + assert_eq!(info.titles.len(), 2); + assert!((info.titles[0].duration_seconds - 5400.0).abs() < 1.0); + assert_eq!(info.titles[0].width, 720); + } } From c6eecae92e02aff780ae34ebbd2a0b2524495aa3 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Thu, 13 Aug 2026 23:20:24 +1000 Subject: [PATCH 5/5] feat(app): accept folders dropped on the queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app has two drop targets — the drop zone for the empty state, the queue panel once something is queued — and each had its own handler. The queue panel filtered dropped paths through a private _isVideoFile copy, which a directory fails, so dropping a folder there did nothing at all and said nothing about why. Only the empty state ever handled folders. Rather than copy the folder and DVD-picker logic into the second widget, move the one implementation into views/dropped_paths.dart and have both call it: handleDroppedPaths, showDvdPicker, isVideoFile, showDropError. A ripped DVD dropped on the queue now opens the title picker, a folder of videos is scanned and queued, and failures surface identically either way. drop_zone's Open Folder and Open DVD buttons go through the same picker, so the disc-drive path cannot drift from the drop path either. Net effect is 140 lines out of drop_zone.dart and the duplicate extension list out of queue_panel.dart. The "Please drop video files or folders" message keeps the drop zone's behaviour of staying quiet when a directory was involved, since folders report their own outcome — queued, title picker, or "No video files found in folder" in the log — and would otherwise produce two contradictory messages for one drop. The drag-and-drop gesture itself arrives over a desktop_drop platform channel and cannot be simulated headlessly, so the tests cover isVideoFile, now the single definition behind both targets, and the routing is exercised through addFolder's own tests. --- app/lib/views/drop_zone.dart | 138 ++----------------------------- app/lib/views/dropped_paths.dart | 131 +++++++++++++++++++++++++++++ app/lib/views/queue_panel.dart | 24 ++---- app/test/dropped_paths_test.dart | 40 +++++++++ 4 files changed, 186 insertions(+), 147 deletions(-) create mode 100644 app/lib/views/dropped_paths.dart create mode 100644 app/test/dropped_paths_test.dart diff --git a/app/lib/views/drop_zone.dart b/app/lib/views/drop_zone.dart index 3bcfdf5..8f1e6fb 100644 --- a/app/lib/views/drop_zone.dart +++ b/app/lib/views/drop_zone.dart @@ -1,14 +1,11 @@ -import 'dart:io'; - import 'package:desktop_drop/desktop_drop.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../models/dvd_info.dart'; import '../services/disc_detector.dart'; import '../viewmodels/main_viewmodel.dart'; -import 'dvd_title_picker.dart'; +import 'dropped_paths.dart'; class DropZone extends StatefulWidget { const DropZone({super.key}); @@ -34,7 +31,7 @@ class _DropZoneState extends State { onDragDone: (details) { setState(() => _isDragging = false); if (details.files.isNotEmpty) { - _handleDroppedPaths( + handleDroppedPaths( context, details.files.map((f) => f.path).toList(), ); @@ -128,47 +125,6 @@ class _DropZoneState extends State { ); } - /// Handle dropped paths (could be files or directories). - Future _handleDroppedPaths(BuildContext context, List paths) async { - final viewModel = context.read(); - final videoFiles = []; - - for (final p in paths) { - final entity = FileSystemEntity.typeSync(p); - - if (entity == FileSystemEntityType.directory) { - // It's a directory — try to add as folder (may trigger DVD flow) - try { - await viewModel.addFolder(p); - } on DvdFolderDetected catch (e) { - if (context.mounted) { - await _showDvdPicker(context, e.mountPoint); - } - } catch (e) { - if (context.mounted) { - _showError(context, 'Failed to open folder: $e'); - } - } - } else if (_isVideoFile(p)) { - videoFiles.add(p); - } - } - - // Add any video files that were dropped - if (videoFiles.isNotEmpty) { - viewModel.addMultipleToQueue(videoFiles); - } else if (paths.isNotEmpty && videoFiles.isEmpty) { - // Only show error if we had paths but found no videos - // (don't show if folders were successfully processed) - final hadDirectories = paths.any( - (p) => FileSystemEntity.typeSync(p) == FileSystemEntityType.directory, - ); - if (!hadDirectories && context.mounted) { - _showError(context, 'Please drop video files or folders'); - } - } - } - Future _pickFile(BuildContext context) async { final result = await FilePicker.platform.pickFiles( type: FileType.custom, @@ -199,11 +155,11 @@ class _DropZoneState extends State { await viewModel.addFolder(folderPath); } on DvdFolderDetected catch (e) { if (context.mounted) { - await _showDvdPicker(context, e.mountPoint); + await showDvdPicker(context, e.mountPoint); } } catch (e) { if (context.mounted) { - _showError(context, 'Failed to open folder: $e'); + showDropError(context, 'Failed to open folder: $e'); } } } @@ -217,13 +173,13 @@ class _DropZoneState extends State { if (!context.mounted) return; if (discs.isEmpty) { - _showError(context, 'No DVD discs detected'); + showDropError(context, 'No DVD discs detected'); return; } if (discs.length == 1) { // Single disc — go directly to title picker - await _showDvdPicker(context, discs.first.mountPoint); + await showDvdPicker(context, discs.first.mountPoint); } else { // Multiple discs — let user choose final disc = await showDialog( @@ -256,88 +212,8 @@ class _DropZoneState extends State { ); if (disc != null && context.mounted) { - await _showDvdPicker(context, disc.mountPoint); + await showDvdPicker(context, disc.mountPoint); } } } - - /// Show the DVD title picker and add selected title to queue. - Future _showDvdPicker(BuildContext context, String mountPoint) async { - final viewModel = context.read(); - - // Show loading indicator - DvdInfo? dvdInfo; - String? error; - - if (!context.mounted) return; - - // Show loading dialog - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => const AlertDialog( - content: Row( - children: [ - CircularProgressIndicator(), - SizedBox(width: 16), - Text('Reading DVD structure...'), - ], - ), - ), - ); - - try { - dvdInfo = await viewModel.getDvdInfo(mountPoint); - } catch (e) { - error = e.toString(); - } - - // Close loading dialog - if (context.mounted) { - Navigator.of(context).pop(); - } - - if (error != null) { - if (context.mounted) { - _showError(context, 'Failed to read DVD: $error'); - } - return; - } - - if (dvdInfo == null || !context.mounted) return; - - // Show title picker - final result = await DvdTitlePicker.show( - context: context, - dvdInfo: dvdInfo, - ); - - if (result != null && context.mounted) { - // Extract and add to queue - viewModel.addDvdTitle( - dvdInfo: dvdInfo, - titleIndex: result.titleIndex, - startChapter: result.startChapter, - endChapter: result.endChapter, - ); - } - } - - bool _isVideoFile(String path) { - final extensions = [ - '.avi', '.mov', '.mp4', '.mkv', '.mxf', '.m2v', '.mpg', '.mpeg', - '.ts', '.vob', '.dv', '.mts', '.m2ts', '.wmv', '.webm', '.flv' - ]; - final lowerPath = path.toLowerCase(); - return extensions.any((ext) => lowerPath.endsWith(ext)); - } - - void _showError(BuildContext context, String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - backgroundColor: Theme.of(context).colorScheme.error, - ), - ); - } } diff --git a/app/lib/views/dropped_paths.dart b/app/lib/views/dropped_paths.dart new file mode 100644 index 0000000..d5b347a --- /dev/null +++ b/app/lib/views/dropped_paths.dart @@ -0,0 +1,131 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/dvd_info.dart'; +import '../viewmodels/main_viewmodel.dart'; +import 'dvd_title_picker.dart'; + +/// Handling for paths dropped on the app, shared by every drop target. +/// +/// There is more than one: the [DropZone] covers the empty state, and the queue +/// panel takes over once the queue is populated. They must agree — a folder that +/// opens a DVD picker on one and is ignored by the other is a bug the user has +/// no way to explain. Keep this the single implementation rather than copying it +/// per widget. + +/// File extensions the app will accept as a video. +const _videoExtensions = { + '.avi', '.mov', '.mp4', '.mkv', '.mxf', '.m2v', '.mpg', '.mpeg', + '.ts', '.vob', '.dv', '.mts', '.m2ts', '.wmv', '.webm', '.flv', +}; + +/// Whether [path] looks like a video file this app can read. +bool isVideoFile(String path) { + final lower = path.toLowerCase(); + return _videoExtensions.any(lower.endsWith); +} + +/// Adds every dropped path to the queue. +/// +/// Directories go through [MainViewModel.addFolder], which routes a ripped DVD +/// to the title picker and scans anything else for videos. Loose files are +/// filtered to videos and queued together. +Future handleDroppedPaths( + BuildContext context, + List paths, +) async { + final viewModel = context.read(); + final videoFiles = []; + var sawDirectory = false; + + for (final path in paths) { + if (FileSystemEntity.typeSync(path) == FileSystemEntityType.directory) { + sawDirectory = true; + try { + await viewModel.addFolder(path); + } on DvdFolderDetected catch (e) { + if (!context.mounted) return; + await showDvdPicker(context, e.mountPoint); + } catch (e) { + if (!context.mounted) return; + showDropError(context, 'Failed to open folder: $e'); + } + } else if (isVideoFile(path)) { + videoFiles.add(path); + } + } + + if (videoFiles.isNotEmpty) { + viewModel.addMultipleToQueue(videoFiles); + } else if (paths.isNotEmpty && !sawDirectory && context.mounted) { + // Folders report their own outcome (queued, DVD picker, or "no videos + // found" in the log), so only complain when nothing usable was dropped. + showDropError(context, 'Please drop video files or folders'); + } +} + +/// Reads the disc at [dvdRoot], shows the title picker, and queues the choice. +Future showDvdPicker(BuildContext context, String dvdRoot) async { + final viewModel = context.read(); + + if (!context.mounted) return; + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const AlertDialog( + content: Row( + children: [ + CircularProgressIndicator(), + SizedBox(width: 16), + Text('Reading DVD structure...'), + ], + ), + ), + ); + + DvdInfo? dvdInfo; + String? error; + try { + dvdInfo = await viewModel.getDvdInfo(dvdRoot); + } catch (e) { + error = e.toString(); + } + + // Close the loading dialog. + if (context.mounted) { + Navigator.of(context).pop(); + } + + if (error != null) { + if (context.mounted) { + showDropError(context, 'Failed to read DVD: $error'); + } + return; + } + + if (dvdInfo == null || !context.mounted) return; + + final result = await DvdTitlePicker.show(context: context, dvdInfo: dvdInfo); + + if (result != null && context.mounted) { + viewModel.addDvdTitle( + dvdInfo: dvdInfo, + titleIndex: result.titleIndex, + startChapter: result.startChapter, + endChapter: result.endChapter, + ); + } +} + +/// Shows a drop-related failure as a snack bar. +void showDropError(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); +} diff --git a/app/lib/views/queue_panel.dart b/app/lib/views/queue_panel.dart index 5ee5a73..03302be 100644 --- a/app/lib/views/queue_panel.dart +++ b/app/lib/views/queue_panel.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import '../models/queue_item.dart'; import '../viewmodels/main_viewmodel.dart'; +import 'dropped_paths.dart'; /// Panel showing the queue of videos to process. class QueuePanel extends StatefulWidget { @@ -33,13 +34,13 @@ class _QueuePanelState extends State { onDragDone: (details) { setState(() => _isDragging = false); if (details.files.isNotEmpty) { - final validPaths = details.files - .where((f) => _isVideoFile(f.path)) - .map((f) => f.path) - .toList(); - if (validPaths.isNotEmpty) { - viewModel.addMultipleToQueue(validPaths); - } + // Shared with the drop zone, so a folder dropped here behaves the + // same as one dropped on the empty state — including opening the + // title picker for a ripped DVD. + handleDroppedPaths( + context, + details.files.map((f) => f.path).toList(), + ); } }, child: Container( @@ -90,15 +91,6 @@ class _QueuePanelState extends State { ); } - bool _isVideoFile(String path) { - final extensions = [ - '.avi', '.mov', '.mp4', '.mkv', '.mxf', '.m2v', '.mpg', '.mpeg', - '.ts', '.vob', '.dv', '.mts', '.m2ts', '.wmv', '.webm', '.flv' - ]; - final lowerPath = path.toLowerCase(); - return extensions.any((ext) => lowerPath.endsWith(ext)); - } - Widget _buildHeader(BuildContext context, MainViewModel viewModel, int count) { final colorScheme = Theme.of(context).colorScheme; diff --git a/app/test/dropped_paths_test.dart b/app/test/dropped_paths_test.dart new file mode 100644 index 0000000..7ba64cd --- /dev/null +++ b/app/test/dropped_paths_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:vapourbox/views/dropped_paths.dart'; + +/// `isVideoFile` is now the single definition behind both drop targets (the drop +/// zone and the queue panel), which previously kept their own copies of the +/// extension list. +void main() { + group('isVideoFile', () { + test('accepts the formats the app reads', () { + for (final name in [ + 'a.avi', 'a.mov', 'a.mp4', 'a.mkv', 'a.mxf', 'a.m2v', 'a.mpg', + 'a.mpeg', 'a.ts', 'a.vob', 'a.dv', 'a.mts', 'a.m2ts', 'a.wmv', + 'a.webm', 'a.flv', + ]) { + expect(isVideoFile(name), isTrue, reason: name); + } + }); + + test('is case-insensitive', () { + expect(isVideoFile('CLIP.MP4'), isTrue); + expect(isVideoFile('VTS_01_1.VOB'), isTrue); + expect(isVideoFile('Movie.MkV'), isTrue); + }); + + test('rejects non-video files', () { + for (final name in [ + 'notes.txt', 'VIDEO_TS.IFO', 'audio.wav', 'image.png', 'no-extension', + 'archive.mp4.zip', + ]) { + expect(isVideoFile(name), isFalse, reason: name); + } + }); + + test('matches on full paths', () { + expect(isVideoFile('/Users/me/My Videos/holiday.mp4'), isTrue); + expect(isVideoFile(r'C:\Users\me\holiday.mkv'), isTrue); + expect(isVideoFile('/Users/me/mp4/readme.md'), isFalse); + }); + }); +}