Added video player in group display, will move to thumbnail for better performance

This commit is contained in:
Alex Tran 2022-02-05 02:37:14 -06:00
commit d546c35e3f
12 changed files with 259 additions and 59 deletions

View file

@ -2,4 +2,4 @@ dev:
docker-compose -f ./server/docker-compose.yml up docker-compose -f ./server/docker-compose.yml up
dev-update: dev-update:
docker-compose -f ./server/docker-compose.yml up --build -V docker-compose -f ./server/docker-compose.yml up --build -V

View file

@ -0,0 +1,20 @@
// Generated file.
// If you wish to remove Flutter's multidex support, delete this entire file.
package io.flutter.app;
import android.content.Context;
import androidx.annotation.CallSuper;
import androidx.multidex.MultiDex;
/**
* Extension of {@link io.flutter.app.FlutterApplication}, adding multidex support.
*/
public class FlutterMultiDexApplication extends FlutterApplication {
@Override
@CallSuper
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
MultiDex.install(this);
}
}

View file

@ -1,6 +1,8 @@
import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/home/ui/thumbnail_image.dart'; import 'package:immich_mobile/modules/home/ui/thumbnail_image.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:video_player/video_player.dart';
class ImageGrid extends StatelessWidget { class ImageGrid extends StatelessWidget {
final List<ImmichAsset> assetGroup; final List<ImmichAsset> assetGroup;
@ -14,9 +16,13 @@ class ImageGrid extends StatelessWidget {
const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, crossAxisSpacing: 5.0, mainAxisSpacing: 5), const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, crossAxisSpacing: 5.0, mainAxisSpacing: 5),
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) { (BuildContext context, int index) {
var assetType = assetGroup[index].type;
return GestureDetector( return GestureDetector(
onTap: () {}, onTap: () {},
child: ThumbnailImage(asset: assetGroup[index]), child: assetType == 'IMAGE'
? ThumbnailImage(asset: assetGroup[index])
: VideoThumbnailPlayer(key: Key(assetGroup[index].id), videoAsset: assetGroup[index]),
); );
}, },
childCount: assetGroup.length, childCount: assetGroup.length,
@ -24,3 +30,56 @@ class ImageGrid extends StatelessWidget {
); );
} }
} }
class VideoThumbnailPlayer extends StatefulWidget {
ImmichAsset videoAsset;
VideoThumbnailPlayer({Key? key, required this.videoAsset}) : super(key: key);
@override
State<VideoThumbnailPlayer> createState() => _VideoThumbnailPlayerState();
}
class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
late VideoPlayerController videoPlayerController;
ChewieController? chewieController;
@override
void initState() {
super.initState();
initializePlayer();
}
Future<void> initializePlayer() async {
videoPlayerController =
VideoPlayerController.network('https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4');
await Future.wait([
videoPlayerController.initialize(),
]);
_createChewieController();
setState(() {});
}
_createChewieController() {
chewieController = ChewieController(
showControlsOnInitialize: false,
videoPlayerController: videoPlayerController,
autoPlay: true,
looping: true,
);
}
@override
Widget build(BuildContext context) {
return chewieController != null && chewieController!.videoPlayerController.value.isInitialized
? SizedBox(
height: 300,
width: 300,
child: Chewie(
controller: chewieController!,
),
)
: const Text("Loading Video");
}
}

View file

@ -5,9 +5,7 @@ class ImmichAsset {
final String deviceAssetId; final String deviceAssetId;
final String userId; final String userId;
final String deviceId; final String deviceId;
final String assetType; final String type;
final String localPath;
final String remotePath;
final String createdAt; final String createdAt;
final String modifiedAt; final String modifiedAt;
final bool isFavorite; final bool isFavorite;
@ -18,9 +16,7 @@ class ImmichAsset {
required this.deviceAssetId, required this.deviceAssetId,
required this.userId, required this.userId,
required this.deviceId, required this.deviceId,
required this.assetType, required this.type,
required this.localPath,
required this.remotePath,
required this.createdAt, required this.createdAt,
required this.modifiedAt, required this.modifiedAt,
required this.isFavorite, required this.isFavorite,
@ -32,9 +28,7 @@ class ImmichAsset {
String? deviceAssetId, String? deviceAssetId,
String? userId, String? userId,
String? deviceId, String? deviceId,
String? assetType, String? type,
String? localPath,
String? remotePath,
String? createdAt, String? createdAt,
String? modifiedAt, String? modifiedAt,
bool? isFavorite, bool? isFavorite,
@ -45,9 +39,7 @@ class ImmichAsset {
deviceAssetId: deviceAssetId ?? this.deviceAssetId, deviceAssetId: deviceAssetId ?? this.deviceAssetId,
userId: userId ?? this.userId, userId: userId ?? this.userId,
deviceId: deviceId ?? this.deviceId, deviceId: deviceId ?? this.deviceId,
assetType: assetType ?? this.assetType, type: type ?? this.type,
localPath: localPath ?? this.localPath,
remotePath: remotePath ?? this.remotePath,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
modifiedAt: modifiedAt ?? this.modifiedAt, modifiedAt: modifiedAt ?? this.modifiedAt,
isFavorite: isFavorite ?? this.isFavorite, isFavorite: isFavorite ?? this.isFavorite,
@ -61,9 +53,7 @@ class ImmichAsset {
'deviceAssetId': deviceAssetId, 'deviceAssetId': deviceAssetId,
'userId': userId, 'userId': userId,
'deviceId': deviceId, 'deviceId': deviceId,
'assetType': assetType, 'type': type,
'localPath': localPath,
'remotePath': remotePath,
'createdAt': createdAt, 'createdAt': createdAt,
'modifiedAt': modifiedAt, 'modifiedAt': modifiedAt,
'isFavorite': isFavorite, 'isFavorite': isFavorite,
@ -77,9 +67,7 @@ class ImmichAsset {
deviceAssetId: map['deviceAssetId'] ?? '', deviceAssetId: map['deviceAssetId'] ?? '',
userId: map['userId'] ?? '', userId: map['userId'] ?? '',
deviceId: map['deviceId'] ?? '', deviceId: map['deviceId'] ?? '',
assetType: map['assetType'] ?? '', type: map['type'] ?? '',
localPath: map['localPath'] ?? '',
remotePath: map['remotePath'] ?? '',
createdAt: map['createdAt'] ?? '', createdAt: map['createdAt'] ?? '',
modifiedAt: map['modifiedAt'] ?? '', modifiedAt: map['modifiedAt'] ?? '',
isFavorite: map['isFavorite'] ?? false, isFavorite: map['isFavorite'] ?? false,
@ -93,7 +81,7 @@ class ImmichAsset {
@override @override
String toString() { String toString() {
return 'ImmichAsset(id: $id, deviceAssetId: $deviceAssetId, userId: $userId, deviceId: $deviceId, assetType: $assetType, localPath: $localPath, remotePath: $remotePath, createdAt: $createdAt, modifiedAt: $modifiedAt, isFavorite: $isFavorite, description: $description)'; return 'ImmichAsset(id: $id, deviceAssetId: $deviceAssetId, userId: $userId, deviceId: $deviceId, type: $type, createdAt: $createdAt, modifiedAt: $modifiedAt, isFavorite: $isFavorite, description: $description)';
} }
@override @override
@ -105,9 +93,7 @@ class ImmichAsset {
other.deviceAssetId == deviceAssetId && other.deviceAssetId == deviceAssetId &&
other.userId == userId && other.userId == userId &&
other.deviceId == deviceId && other.deviceId == deviceId &&
other.assetType == assetType && other.type == type &&
other.localPath == localPath &&
other.remotePath == remotePath &&
other.createdAt == createdAt && other.createdAt == createdAt &&
other.modifiedAt == modifiedAt && other.modifiedAt == modifiedAt &&
other.isFavorite == isFavorite && other.isFavorite == isFavorite &&
@ -120,9 +106,7 @@ class ImmichAsset {
deviceAssetId.hashCode ^ deviceAssetId.hashCode ^
userId.hashCode ^ userId.hashCode ^
deviceId.hashCode ^ deviceId.hashCode ^
assetType.hashCode ^ type.hashCode ^
localPath.hashCode ^
remotePath.hashCode ^
createdAt.hashCode ^ createdAt.hashCode ^
modifiedAt.hashCode ^ modifiedAt.hashCode ^
isFavorite.hashCode ^ isFavorite.hashCode ^

View file

@ -155,6 +155,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.1" version: "2.0.1"
chewie:
dependency: "direct main"
description:
name: chewie
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.2"
cli_util: cli_util:
dependency: transitive dependency: transitive
description: description:
@ -527,6 +534,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.1" version: "1.0.1"
nested:
dependency: transitive
description:
name: nested
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
octo_image: octo_image:
dependency: transitive dependency: transitive
description: description:
@ -653,6 +667,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.2.4" version: "4.2.4"
provider:
dependency: transitive
description:
name: provider
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.0"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
@ -847,6 +868,41 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.1" version: "2.1.1"
video_player:
dependency: "direct main"
description:
name: video_player
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.18"
video_player_android:
dependency: transitive
description:
name: video_player_android
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.17"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.18"
video_player_platform_interface:
dependency: transitive
description:
name: video_player_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.1"
video_player_web:
dependency: transitive
description:
name: video_player_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.6"
visibility_detector: visibility_detector:
dependency: "direct main" dependency: "direct main"
description: description:
@ -854,6 +910,41 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.2.2" version: "0.2.2"
wakelock:
dependency: transitive
description:
name: wakelock
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.6"
wakelock_macos:
dependency: transitive
description:
name: wakelock_macos
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.0"
wakelock_platform_interface:
dependency: transitive
description:
name: wakelock_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.0"
wakelock_web:
dependency: transitive
description:
name: wakelock_web
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.0"
wakelock_windows:
dependency: transitive
description:
name: wakelock_windows
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0"
watcher: watcher:
dependency: transitive dependency: transitive
description: description:
@ -898,4 +989,4 @@ packages:
version: "3.1.0" version: "3.1.0"
sdks: sdks:
dart: ">=2.15.1 <3.0.0" dart: ">=2.15.1 <3.0.0"
flutter: ">=2.5.0" flutter: ">=2.8.0"

View file

@ -28,6 +28,8 @@ dependencies:
visibility_detector: ^0.2.2 visibility_detector: ^0.2.2
flutter_launcher_icons: "^0.9.2" flutter_launcher_icons: "^0.9.2"
fluttertoast: ^8.0.8 fluttertoast: ^8.0.8
video_player: ^2.2.18
chewie: ^1.2.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View file

@ -15,7 +15,8 @@ RUN apt-get update && apt-get install -y --fix-missing --no-install-recommends \
rsync \ rsync \
software-properties-common \ software-properties-common \
unzip \ unzip \
wget wget \
ffmpeg
# Install NodeJS # Install NodeJS
RUN curl --silent --location https://deb.nodesource.com/setup_14.x | bash - RUN curl --silent --location https://deb.nodesource.com/setup_14.x | bash -
@ -54,7 +55,8 @@ RUN apt-get update && apt-get install -y --fix-missing --no-install-recommends \
rsync \ rsync \
software-properties-common \ software-properties-common \
unzip \ unzip \
wget wget \
ffmpeg
# Install NodeJS # Install NodeJS
RUN curl --silent --location https://deb.nodesource.com/setup_14.x | bash - RUN curl --silent --location https://deb.nodesource.com/setup_14.x | bash -

View file

@ -1,3 +1,6 @@
##################################
# DEVELOPMENT
##################################
FROM node:16-bullseye-slim AS development FROM node:16-bullseye-slim AS development
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
@ -7,7 +10,7 @@ WORKDIR /usr/src/app
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN apt-get update RUN apt-get update
RUN apt-get install gcc g++ make cmake python3 python3-pip -y RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
RUN npm i -g yarn --force RUN npm i -g yarn --force
@ -17,6 +20,18 @@ COPY . .
RUN yarn build RUN yarn build
# Clean up commands
RUN apt-get autoremove -y && apt-get clean && \
rm -rf /usr/local/src/*
RUN apt-get clean && \
rm -rf /var/lib/apt/lists/*
##################################
# PRODUCTION
##################################
FROM node:16-bullseye-slim as production FROM node:16-bullseye-slim as production
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
ARG NODE_ENV=production ARG NODE_ENV=production
@ -27,7 +42,7 @@ WORKDIR /usr/src/app
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN apt-get update RUN apt-get update
RUN apt-get install gcc g++ make cmake python3 python3-pip -y RUN apt-get install gcc g++ make cmake python3 python3-pip ffmpeg -y
RUN npm i -g yarn --force RUN npm i -g yarn --force
@ -37,4 +52,12 @@ COPY . .
COPY --from=development /usr/src/app/dist ./dist COPY --from=development /usr/src/app/dist ./dist
# Clean up commands
RUN apt-get autoremove -y && apt-get clean && \
rm -rf /usr/local/src/*
RUN apt-get clean && \
rm -rf /var/lib/apt/lists/*
CMD ["node", "dist/main"] CMD ["node", "dist/main"]

View file

@ -36,12 +36,12 @@
"@tensorflow/tfjs-converter": "^3.13.0", "@tensorflow/tfjs-converter": "^3.13.0",
"@tensorflow/tfjs-core": "^3.13.0", "@tensorflow/tfjs-core": "^3.13.0",
"@tensorflow/tfjs-node": "^3.13.0", "@tensorflow/tfjs-node": "^3.13.0",
"@types/sharp": "^0.29.5",
"bcrypt": "^5.0.1", "bcrypt": "^5.0.1",
"bull": "^4.4.0", "bull": "^4.4.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.13.2", "class-validator": "^0.13.2",
"dotenv": "^14.2.0", "dotenv": "^14.2.0",
"fluent-ffmpeg": "^2.1.2",
"joi": "^17.5.0", "joi": "^17.5.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"passport": "^0.5.2", "passport": "^0.5.2",
@ -61,12 +61,14 @@
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/bull": "^3.15.7", "@types/bull": "^3.15.7",
"@types/express": "^4.17.13", "@types/express": "^4.17.13",
"@types/fluent-ffmpeg": "^2.1.20",
"@types/imagemin": "^8.0.0", "@types/imagemin": "^8.0.0",
"@types/jest": "27.0.2", "@types/jest": "27.0.2",
"@types/lodash": "^4.14.178", "@types/lodash": "^4.14.178",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^16.0.0", "@types/node": "^16.0.0",
"@types/passport-jwt": "^3.0.6", "@types/passport-jwt": "^3.0.6",
"@types/sharp": "^0.29.5",
"@types/supertest": "^2.0.11", "@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0", "@typescript-eslint/parser": "^5.0.0",

View file

@ -48,7 +48,7 @@ export class AssetController {
await this.assetOptimizeService.resizeImage(savedAsset); await this.assetOptimizeService.resizeImage(savedAsset);
} }
if (savedAsset && savedAsset.type == AssetType.IMAGE) { if (savedAsset && savedAsset.type == AssetType.VIDEO) {
await this.assetOptimizeService.resizeVideo(savedAsset); await this.assetOptimizeService.resizeVideo(savedAsset);
} }
}); });

View file

@ -6,7 +6,8 @@ import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
import sharp from 'sharp'; import sharp from 'sharp';
import fs, { existsSync, mkdirSync } from 'fs'; import fs, { existsSync, mkdirSync } from 'fs';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { randomUUID } from 'crypto'; import ffmpeg from 'fluent-ffmpeg';
import { Logger } from '@nestjs/common';
@Processor('optimize') @Processor('optimize')
export class ImageOptimizeProcessor { export class ImageOptimizeProcessor {
@ -73,30 +74,19 @@ export class ImageOptimizeProcessor {
mkdirSync(resizeDir, { recursive: true }); mkdirSync(resizeDir, { recursive: true });
} }
fs.readFile(savedAsset.originalPath, (err, data) => { ffmpeg(savedAsset.originalPath)
if (err) { .output(resizePath)
console.error('Error Reading File'); .noAudio()
} .videoCodec('libx264')
.size('640x?')
sharp(data) .aspect('4:3')
.resize(512, 512, { fit: 'outside' }) .on('error', (e) => {
.toFile(resizePath, async (err, info) => { Logger.log(`Error resizing File: ${e}`, 'resizeUploadedVideo');
if (err) { })
console.error('Error resizing file ', err); .on('end', async () => {
} await this.assetRepository.update(savedAsset, { resizePath: resizePath });
})
await this.assetRepository.update(savedAsset, { resizePath: resizePath }); .run();
// Send file to object detection after resizing
// const detectionJob = await this.machineLearningQueue.add(
// 'object-detection',
// {
// resizePath,
// },
// { jobId: randomUUID() },
// );
});
});
return 'ok'; return 'ok';
} }

View file

@ -1039,6 +1039,13 @@
"@types/qs" "*" "@types/qs" "*"
"@types/serve-static" "*" "@types/serve-static" "*"
"@types/fluent-ffmpeg@^2.1.20":
version "2.1.20"
resolved "https://registry.yarnpkg.com/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.20.tgz#3b5f42fc8263761d58284fa46ee6759a64ce54ac"
integrity sha512-B+OvhCdJ3LgEq2PhvWNOiB/EfwnXLElfMCgc4Z1K5zXgSfo9I6uGKwR/lqmNPFQuebNnes7re3gqkV77SyypLg==
dependencies:
"@types/node" "*"
"@types/graceful-fs@^4.1.2": "@types/graceful-fs@^4.1.2":
version "4.1.5" version "4.1.5"
resolved "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz" resolved "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz"
@ -1739,6 +1746,11 @@ asap@^2.0.0:
resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz" resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz"
integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=
async@>=0.2.9:
version "3.2.3"
resolved "https://registry.yarnpkg.com/async/-/async-3.2.3.tgz#ac53dafd3f4720ee9e8a160628f18ea91df196c9"
integrity sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==
asynckit@^0.4.0: asynckit@^0.4.0:
version "0.4.0" version "0.4.0"
resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz" resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz"
@ -3094,6 +3106,14 @@ flatted@^3.1.0:
resolved "https://registry.npmjs.org/flatted/-/flatted-3.2.4.tgz" resolved "https://registry.npmjs.org/flatted/-/flatted-3.2.4.tgz"
integrity sha512-8/sOawo8tJ4QOBX8YlQBMxL8+RLZfxMQOif9o0KUKTNTjMYElWPE0r/m5VNFxTRd0NSw8qSy8dajrwX4RYI1Hw== integrity sha512-8/sOawo8tJ4QOBX8YlQBMxL8+RLZfxMQOif9o0KUKTNTjMYElWPE0r/m5VNFxTRd0NSw8qSy8dajrwX4RYI1Hw==
fluent-ffmpeg@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/fluent-ffmpeg/-/fluent-ffmpeg-2.1.2.tgz#c952de2240f812ebda0aa8006d7776ee2acf7d74"
integrity sha1-yVLeIkD4EuvaCqgAbXd27irPfXQ=
dependencies:
async ">=0.2.9"
which "^1.1.1"
follow-redirects@^1.14.4: follow-redirects@^1.14.4:
version "1.14.7" version "1.14.7"
resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.7.tgz" resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.7.tgz"
@ -6467,6 +6487,13 @@ whatwg-url@^8.0.0, whatwg-url@^8.5.0:
tr46 "^2.1.0" tr46 "^2.1.0"
webidl-conversions "^6.1.0" webidl-conversions "^6.1.0"
which@^1.1.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
dependencies:
isexe "^2.0.0"
which@^2.0.1: which@^2.0.1:
version "2.0.2" version "2.0.2"
resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz" resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz"