Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions build_desktop_linux.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,16 @@ EOF

cat << 'EOF' > "$DEB_DIR/DEBIAN/control"
Package: mediacrush
Version: 1.0.4
Version: 1.0.5
Section: video
Priority: optional
Architecture: amd64
Maintainer: ChromaBeast <sheersh@chromabeast.com>
Description: Universal Hardware-Accelerated Media Optimizer
EOF

dpkg-deb --build "$DEB_DIR" ../dist/mediacrush_1.0.4_amd64.deb || true
dpkg-deb --build "$DEB_DIR" ../dist/mediacrush_1.0.5_amd64.deb || true
cp ../dist/mediacrush_1.0.5_amd64.deb ../dist/mediacrush_amd64.deb 2>/dev/null || true
rm -rf "$DEB_DIR"

echo "Linux packages assembled in dist/"
2 changes: 1 addition & 1 deletion desktop/lib/services/update_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import '../models/update_info.dart';
class UpdateService {
static const String repoOwner = 'ChromaBeast';
static const String repoName = 'mediacrush';
static const String currentVersion = '1.0.4';
static const String currentVersion = '1.0.5';

/// Queries GitHub Releases API for the latest published release.
Future<UpdateInfo> checkForUpdates() async {
Expand Down
2 changes: 1 addition & 1 deletion desktop/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.4+5
version: 1.0.5+6

environment:
sdk: ^3.13.5
Expand Down
167 changes: 167 additions & 0 deletions desktop/test/app_state_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import 'dart:io';

import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:desktop/models/hardware_engine.dart';
import 'package:desktop/state/app_state.dart';

void main() {
late Directory tmp;
late AppState state;
int notifications = 0;

setUp(() {
tmp = Directory.systemTemp.createTempSync('mc_state_');
state = AppState();
notifications = 0;
state.addListener(() => notifications++);
});

tearDown(() {
state.dispose();
if (tmp.existsSync()) tmp.deleteSync(recursive: true);
});

File make(String name, int bytes) {
final f = File(p.join(tmp.path, name))..createSync(recursive: true);
f.writeAsBytesSync(List.filled(bytes, 0));
return f;
}

group('initial state', () {
test('is empty and idle', () {
expect(state.items, isEmpty);
expect(state.isScanning, isFalse);
expect(state.isCompressing, isFalse);
expect(state.job, isNull);
expect(state.preset, 'anime');
expect(state.outputFolder, isNull);
expect(state.totalCandidates, 0);
expect(state.savingsPercentage, 0.0);
expect(state.hardware.name, 'libx265');
});
});

group('setters notify listeners', () {
test('preset', () {
state.setPreset('saver');
expect(state.preset, 'saver');
expect(notifications, 1);
});

test('output folder', () {
state.setOutputFolder('/out');
expect(state.outputFolder, '/out');
expect(notifications, 1);
});

test('hardware', () {
state.setHardware(HardwareEngine.fromCliName('nvenc'));
expect(state.hardware.vendor, 'NVIDIA');
expect(notifications, 1);
});
});

group('addFiles', () {
test('adds existing files with estimate and default output folder', () {
final f = make('a.mkv', 1000);
state.addFiles([f.path]);
expect(state.items, hasLength(1));
expect(state.items.first.fileName, 'a.mkv');
expect(state.items.first.originalBytes, 1000);
expect(state.items.first.estimatedBytes, 450);
expect(state.outputFolder, p.join(tmp.path, 'crushed'));
expect(notifications, 1);
});

test('ignores missing files and duplicates', () {
final f = make('a.mkv', 100);
state.addFiles([f.path, f.path, p.join(tmp.path, 'ghost.mkv')]);
expect(state.items, hasLength(1));
});

test('empty list is a no-op and does not notify', () {
state.addFiles([]);
expect(notifications, 0);
});

test('keeps an explicitly chosen output folder', () {
state.setOutputFolder('/custom');
state.addFiles([make('a.mkv', 10).path]);
expect(state.outputFolder, '/custom');
});

test('aggregates totals and savings', () {
state.addFiles([make('a.mkv', 1000).path, make('b.mkv', 3000).path]);
expect(state.totalCandidates, 2);
expect(state.totalOriginalBytes, 4000);
expect(state.totalEstimatedBytes, 1800);
expect(state.totalSavedBytes, 2200);
expect(state.savingsPercentage, closeTo(55.0, 1e-9));
});
});

group('scanFolder', () {
test('finds only video files recursively and sets folders', () async {
make('a.mkv', 10);
make('sub/b.MP4', 10);
make('notes.txt', 10);
await state.scanFolder(tmp.path);
expect(state.items.map((i) => i.fileName).toSet(), {'a.mkv', 'b.MP4'});
expect(state.selectedFolder, tmp.path);
expect(state.outputFolder, p.join(tmp.path, 'crushed'));
expect(state.isScanning, isFalse);
expect(notifications, greaterThanOrEqualTo(2)); // scanning on, scanning off
});

test('missing folder yields no items', () async {
await state.scanFolder(p.join(tmp.path, 'nope'));
expect(state.items, isEmpty);
expect(state.isScanning, isFalse);
});

test('rescan replaces previous items', () async {
make('a.mkv', 10);
await state.scanFolder(tmp.path);
File(p.join(tmp.path, 'a.mkv')).deleteSync();
await state.scanFolder(tmp.path);
expect(state.items, isEmpty);
});
});

group('clearItems', () {
test('empties list and selection', () async {
make('a.mkv', 10);
await state.scanFolder(tmp.path);
state.clearItems();
expect(state.items, isEmpty);
expect(state.selectedFolder, isNull);
});
});

group('compression control', () {
test('startCompression with nothing queued does nothing', () async {
await state.startCompression();
expect(state.job, isNull);
expect(state.isCompressing, isFalse);
});

test('pauseCompression is safe when idle and notifies', () {
state.pauseCompression();
expect(state.isCompressing, isFalse);
expect(notifications, 1);
});

test('a bogus input fails the item without crashing and finishes the job', () async {
// Input exists on disk but is not a real video, so ffmpeg (or its absence) must fail cleanly.
final f = make('fake.mkv', 2048);
state.addFiles([f.path]);
state.setOutputFolder(p.join(tmp.path, 'out'));
await state.startCompression();
expect(state.isCompressing, isFalse);
expect(state.job!.isRunning, isFalse);
expect(state.job!.completedFiles, 0);
expect(state.items.first.status.name, 'failed');
}, timeout: const Timeout(Duration(seconds: 60)));
});
}
43 changes: 43 additions & 0 deletions desktop/test/compression_job_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:desktop/models/compression_job.dart';

void main() {
group('CompressionJob', () {
test('defaults', () {
final j = CompressionJob(totalFiles: 3);
expect(j.completedFiles, 0);
expect(j.overallProgress, 0.0);
expect(j.isRunning, isFalse);
expect(j.isPaused, isFalse);
expect(j.currentSpeedMultiplier, 1.0);
expect(j.totalSavedBytes, 0);
});

test('totalSavedBytes is the difference', () {
final j = CompressionJob(totalFiles: 1, originalTotalBytes: 1000, finalTotalBytes: 400);
expect(j.totalSavedBytes, 600);
expect(j.overallSavingsPercentage, closeTo(60.0, 1e-9));
});

test('totalSavedBytes never negative when output grows', () {
final j = CompressionJob(totalFiles: 1, originalTotalBytes: 100, finalTotalBytes: 500);
expect(j.totalSavedBytes, 0);
expect(j.overallSavingsPercentage, 0.0);
});

test('percentage is zero when nothing to compress', () {
expect(CompressionJob(totalFiles: 0).overallSavingsPercentage, 0.0);
expect(CompressionJob(totalFiles: 1, originalTotalBytes: -5).overallSavingsPercentage, 0.0);
});

test('fields are mutable for live progress', () {
final j = CompressionJob(totalFiles: 2)
..completedFiles = 1
..overallProgress = 0.5
..isRunning = true;
expect(j.completedFiles, 1);
expect(j.overallProgress, 0.5);
expect(j.isRunning, isTrue);
});
});
}
92 changes: 92 additions & 0 deletions desktop/test/ffmpeg_and_scan_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import 'dart:io';

import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:desktop/models/hardware_engine.dart';
import 'package:desktop/services/mediacrush_cli.dart';
import 'package:desktop/services/transcode_service.dart';
import 'package:desktop/utils/ffmpeg_locator.dart';

void main() {
group('FfmpegLocator', () {
test('always returns a non-empty binary name/path', () async {
expect(await FfmpegLocator.getFfmpegPath(), isNotEmpty);
expect(await FfmpegLocator.getFfprobePath(), isNotEmpty);
});

test('path lookups are stable (cached)', () async {
expect(await FfmpegLocator.getFfmpegPath(), await FfmpegLocator.getFfmpegPath());
});

test('availability agrees with the resolved binary', () async {
final available = await FfmpegLocator.isFfmpegAvailable();
final path = await FfmpegLocator.getFfmpegPath();
final res = await Process.run(path, ['-version']).then((r) => r.exitCode == 0, onError: (_) => false);
expect(available, res);
});
});

group('MediaCrushCli.scanDirectory', () {
late Directory tmp;
final cli = MediaCrushCli();

setUp(() => tmp = Directory.systemTemp.createTempSync('mc_scan_'));
tearDown(() => tmp.deleteSync(recursive: true));

void touch(String rel, [int bytes = 4]) {
File(p.join(tmp.path, rel))
..createSync(recursive: true)
..writeAsBytesSync(List.filled(bytes, 1));
}

test('every supported extension is picked up (case-insensitive)', () async {
for (final ext in MediaCrushCli.videoExtensions) {
touch('v$ext');
touch('UP${ext.toUpperCase()}');
}
final items = await cli.scanDirectory(tmp.path);
expect(items, hasLength(MediaCrushCli.videoExtensions.length * 2));
});

test('ignores non-video files', () async {
touch('a.txt');
touch('b.jpg');
touch('c.srt');
expect(await cli.scanDirectory(tmp.path), isEmpty);
});

test('recurses and records size + 45% estimate', () async {
touch('deep/er/clip.mkv', 2000);
final items = await cli.scanDirectory(tmp.path);
expect(items.single.originalBytes, 2000);
expect(items.single.estimatedBytes, 900);
expect(items.single.fileName, 'clip.mkv');
});

test('empty directory', () async {
expect(await cli.scanDirectory(tmp.path), isEmpty);
});
});

group('TranscodeService', () {
test('returns false and leaves no output for a missing input', () async {
final tmp = Directory.systemTemp.createTempSync('mc_tc_');
addTearDown(() => tmp.deleteSync(recursive: true));
final out = p.join(tmp.path, 'out.mp4');
final progress = <double>[];
final ok = await TranscodeService().transcode(
inputPath: p.join(tmp.path, 'missing.mkv'),
outputPath: out,
engine: HardwareEngine.fallback(),
preset: 'film',
onProgress: progress.add,
);
expect(ok, isFalse);
expect(File(out).existsSync(), isFalse);
}, timeout: const Timeout(Duration(seconds: 60)));

test('cancel is safe with no active process', () {
TranscodeService().cancel();
});
});
}
Loading
Loading