transmission/web/src/torrent.js

646 lines
15 KiB
JavaScript

/* @license This file Copyright (C) 2020-2022 Mnemosyne LLC.
It may be used under GPLv2 (SPDX: GPL-2.0-only), GPLv3 (SPDX: GPL-3.0-only),
or any future license endorsed by Mnemosyne LLC.
License text can be found in the licenses/ folder. */
import { Formatter } from './formatter.js';
import { Prefs } from './prefs.js';
import { deepEqual } from './utils.js';
///
export class Torrent extends EventTarget {
constructor(data) {
super();
this.fieldObservers = {};
this.fields = {};
this.refresh(data);
}
notifyOnFieldChange(field, callback) {
this.fieldObservers[field] = this.fieldObservers[field] || [];
this.fieldObservers[field].push(callback);
}
setField(o, name, value) {
const old_value = o[name];
if (deepEqual(old_value, value)) {
return false;
}
const observers = this.fieldObservers[name];
if (o === this.fields && observers && observers.length > 0) {
for (const observer of observers) {
observer.call(this, value, old_value, name);
}
}
o[name] = value;
return true;
}
// fields.files is an array of unions of RPC's "files" and "fileStats" objects.
updateFiles(files) {
let changed = false;
const myfiles = this.fields.files || [];
const keys = ['length', 'name', 'bytesCompleted', 'wanted', 'priority'];
for (const [index, f] of files.entries()) {
const myfile = myfiles[index] || {};
for (const key of keys) {
if (key in f) {
changed |= this.setField(myfile, key, f[key]);
}
}
myfiles[index] = myfile;
}
this.fields.files = myfiles;
return changed;
}
static collateTrackers(trackers) {
return trackers.map((t) => t.announce.toLowerCase()).join('\t');
}
refreshFields(data) {
let changed = false;
for (const [key, value] of Object.entries(data)) {
switch (key) {
case 'files':
case 'fileStats': // merge files and fileStats together
changed |= this.updateFiles(value);
break;
case 'trackerStats': // 'trackerStats' is a superset of 'trackers'...
changed |= this.setField(this.fields, 'trackers', value);
break;
case 'trackers': // ...so only save 'trackers' if we don't have it already
if (!(key in this.fields)) {
changed |= this.setField(this.fields, key, value);
}
break;
case 'name':
if (this.setField(this.fields, key, data[key])) {
this.fields.collatedName = '';
changed = true;
}
break;
default:
changed |= this.setField(this.fields, key, value);
}
}
return changed;
}
refresh(data) {
if (this.refreshFields(data)) {
this.dispatchEvent(new Event('dataChanged'));
}
}
///
// simple accessors
getComment() {
return this.fields.comment;
}
getCreator() {
return this.fields.creator;
}
getDateAdded() {
return this.fields.addedDate;
}
getDateCreated() {
return this.fields.dateCreated;
}
getDesiredAvailable() {
return this.fields.desiredAvailable;
}
getDownloadDir() {
return this.fields.downloadDir;
}
getDownloadSpeed() {
return this.fields.rateDownload;
}
getDownloadedEver() {
return this.fields.downloadedEver;
}
getError() {
return this.fields.error;
}
getErrorString() {
return this.fields.errorString;
}
getETA() {
return this.fields.eta;
}
getFailedEver() {
return this.fields.corruptEver;
}
getFiles() {
return this.fields.files || [];
}
getFile(index) {
return this.fields.files[index];
}
getFileCount() {
return this.fields['file-count'];
}
getHashString() {
return this.fields.hashString;
}
getHave() {
return this.getHaveValid() + this.getHaveUnchecked();
}
getHaveUnchecked() {
return this.fields.haveUnchecked;
}
getHaveValid() {
return this.fields.haveValid;
}
getId() {
return this.fields.id;
}
getLabels() {
return this.fields.labels.sort();
}
getLastActivity() {
return this.fields.activityDate;
}
getLeftUntilDone() {
return this.fields.leftUntilDone;
}
getMagnetLink() {
return this.fields.magnetLink;
}
getMetadataPercentComplete() {
return this.fields.metadataPercentComplete;
}
getName() {
return this.fields.name || 'Unknown';
}
getPeers() {
return this.fields.peers || [];
}
getPeersConnected() {
return this.fields.peersConnected;
}
getPeersGettingFromUs() {
return this.fields.peersGettingFromUs;
}
getPeersSendingToUs() {
return this.fields.peersSendingToUs;
}
getPieceCount() {
return this.fields.pieceCount;
}
getPieceSize() {
return this.fields.pieceSize;
}
getPrimaryMimeType() {
return this.fields['primary-mime-type'];
}
getPrivateFlag() {
return this.fields.isPrivate;
}
getQueuePosition() {
return this.fields.queuePosition;
}
getRecheckProgress() {
return this.fields.recheckProgress;
}
getSeedRatioLimit() {
return this.fields.seedRatioLimit;
}
getSeedRatioMode() {
return this.fields.seedRatioMode;
}
getSizeWhenDone() {
return this.fields.sizeWhenDone;
}
getStartDate() {
return this.fields.startDate;
}
getStatus() {
return this.fields.status;
}
getTotalSize() {
return this.fields.totalSize;
}
getTrackers() {
return this.fields.trackers || [];
}
getUploadSpeed() {
return this.fields.rateUpload;
}
getUploadRatio() {
return this.fields.uploadRatio;
}
getUploadedEver() {
return this.fields.uploadedEver;
}
getWebseedsSendingToUs() {
return this.fields.webseedsSendingToUs;
}
isFinished() {
return this.fields.isFinished;
}
// derived accessors
hasExtraInfo() {
return 'hashString' in this.fields;
}
isSeeding() {
return this.getStatus() === Torrent._StatusSeed;
}
isStopped() {
return this.getStatus() === Torrent._StatusStopped;
}
isChecking() {
return this.getStatus() === Torrent._StatusCheck;
}
isDownloading() {
return this.getStatus() === Torrent._StatusDownload;
}
isQueued() {
return (
this.getStatus() === Torrent._StatusDownloadWait ||
this.getStatus() === Torrent._StatusSeedWait
);
}
isDone() {
return this.getLeftUntilDone() < 1;
}
needsMetaData() {
return this.getMetadataPercentComplete() < 1;
}
getActivity() {
return this.getDownloadSpeed() + this.getUploadSpeed();
}
getPercentDoneStr() {
return Formatter.percentString(100 * this.getPercentDone());
}
getPercentDone() {
return this.fields.percentDone;
}
getStateString() {
switch (this.getStatus()) {
case Torrent._StatusStopped:
return this.isFinished() ? 'Seeding complete' : 'Paused';
case Torrent._StatusCheckWait:
return 'Queued for verification';
case Torrent._StatusCheck:
return 'Verifying local data';
case Torrent._StatusDownloadWait:
return 'Queued for download';
case Torrent._StatusDownload:
return 'Downloading';
case Torrent._StatusSeedWait:
return 'Queued for seeding';
case Torrent._StatusSeed:
return 'Seeding';
case null:
return 'Unknown';
default:
return 'Error';
}
}
seedRatioLimit(controller) {
switch (this.getSeedRatioMode()) {
case Torrent._RatioUseGlobal:
return controller.seedRatioLimit();
case Torrent._RatioUseLocal:
return this.getSeedRatioLimit();
default:
return -1;
}
}
getErrorMessage() {
const string = this.getErrorString();
switch (this.getError()) {
case Torrent._ErrTrackerWarning:
return `Tracker returned a warning: ${string}`;
case Torrent._ErrTrackerError:
return `Tracker returned an error: ${string}`;
case Torrent._ErrLocalError:
return `Error: ${string}`;
default:
return null;
}
}
getCollatedName() {
const f = this.fields;
if (!f.collatedName && f.name) {
f.collatedName = f.name.toLowerCase();
}
return f.collatedName || '';
}
getCollatedTrackers() {
const f = this.fields;
if (!f.collatedTrackers && f.trackers) {
f.collatedTrackers = Torrent.collateTrackers(f.trackers);
}
return f.collatedTrackers || '';
}
/****
*****
****/
testState(state) {
const s = this.getStatus();
switch (state) {
case Prefs.FilterActive:
return (
this.getPeersGettingFromUs() > 0 ||
this.getPeersSendingToUs() > 0 ||
this.getWebseedsSendingToUs() > 0 ||
this.isChecking()
);
case Prefs.FilterSeeding:
return s === Torrent._StatusSeed || s === Torrent._StatusSeedWait;
case Prefs.FilterDownloading:
return (
s === Torrent._StatusDownload || s === Torrent._StatusDownloadWait
);
case Prefs.FilterPaused:
return this.isStopped();
case Prefs.FilterFinished:
return this.isFinished();
default:
return true;
}
}
/**
* @param state one of Prefs.Filter*
* @param tracker tracker name
* @param search substring to look for, or null
* @param labels array of labels. Empty array matches all.
* @return true if it passes the test, false if it fails
*/
test(state, tracker, search, labels) {
// filter by state...
let pass = this.testState(state);
// maybe filter by text...
if (pass && search) {
pass = this.getCollatedName().includes(search.toLowerCase());
}
// maybe filter by labels...
if (pass) {
// pass if this torrent has any of these labels
const torrent_labels = this.getLabels();
if (labels.length > 0) {
pass = labels.some((label) => torrent_labels.includes(label));
}
}
// maybe filter by tracker...
if (pass && tracker && tracker.length > 0) {
pass = this.getCollatedTrackers().includes(tracker);
}
return pass;
}
static compareById(ta, tb) {
return ta.getId() - tb.getId();
}
static compareByName(ta, tb) {
return (
ta.getCollatedName().localeCompare(tb.getCollatedName()) ||
Torrent.compareById(ta, tb)
);
}
static compareByQueue(ta, tb) {
return ta.getQueuePosition() - tb.getQueuePosition();
}
static compareByAge(ta, tb) {
const a = ta.getDateAdded();
const b = tb.getDateAdded();
return b - a || Torrent.compareByQueue(ta, tb);
}
static compareByState(ta, tb) {
const a = ta.getStatus();
const b = tb.getStatus();
return b - a || Torrent.compareByQueue(ta, tb);
}
static compareByActivity(ta, tb) {
const a = ta.getActivity();
const b = tb.getActivity();
return b - a || Torrent.compareByState(ta, tb);
}
static compareByRatio(ta, tb) {
const a = ta.getUploadRatio();
const b = tb.getUploadRatio();
if (a < b) {
return 1;
}
if (a > b) {
return -1;
}
return Torrent.compareByState(ta, tb);
}
static compareByProgress(ta, tb) {
const a = ta.getPercentDone();
const b = tb.getPercentDone();
return a - b || Torrent.compareByRatio(ta, tb);
}
static compareBySize(ta, tb) {
const a = ta.getTotalSize();
const b = tb.getTotalSize();
return a - b || Torrent.compareByName(ta, tb);
}
static compareTorrents(a, b, sortMode, sortDirection) {
let index = 0;
switch (sortMode) {
case Prefs.SortByActivity:
index = Torrent.compareByActivity(a, b);
break;
case Prefs.SortByAge:
index = Torrent.compareByAge(a, b);
break;
case Prefs.SortByQueue:
index = Torrent.compareByQueue(a, b);
break;
case Prefs.SortByProgress:
index = Torrent.compareByProgress(a, b);
break;
case Prefs.SortBySize:
index = Torrent.compareBySize(a, b);
break;
case Prefs.SortByState:
index = Torrent.compareByState(a, b);
break;
case Prefs.SortByRatio:
index = Torrent.compareByRatio(a, b);
break;
case Prefs.SortByName:
index = Torrent.compareByName(a, b);
break;
default:
console.log(`Unrecognized sort mode: ${sortMode}`);
index = Torrent.compareByName(a, b);
break;
}
if (sortDirection === Prefs.SortDescending) {
index = -index;
}
return index;
}
/**
* @param torrents an array of Torrent objects
* @param sortMode one of Prefs.SortBy*
* @param sortDirection Prefs.SortAscending or Prefs.SortDescending
*/
static sortTorrents(torrents, sortMode, sortDirection) {
switch (sortMode) {
case Prefs.SortByActivity:
torrents.sort(this.compareByActivity);
break;
case Prefs.SortByAge:
torrents.sort(this.compareByAge);
break;
case Prefs.SortByName:
torrents.sort(this.compareByName);
break;
case Prefs.SortByProgress:
torrents.sort(this.compareByProgress);
break;
case Prefs.SortByQueue:
torrents.sort(this.compareByQueue);
break;
case Prefs.SortByRatio:
torrents.sort(this.compareByRatio);
break;
case Prefs.SortBySize:
torrents.sort(this.compareBySize);
break;
case Prefs.SortByState:
torrents.sort(this.compareByState);
break;
default:
console.log(`Unrecognized sort mode: ${sortMode}`);
torrents.sort(this.compareByName);
break;
}
if (sortDirection === Prefs.SortDescending) {
torrents.reverse();
}
return torrents;
}
}
// Torrent.fields.status
Torrent._StatusStopped = 0;
Torrent._StatusCheckWait = 1;
Torrent._StatusCheck = 2;
Torrent._StatusDownloadWait = 3;
Torrent._StatusDownload = 4;
Torrent._StatusSeedWait = 5;
Torrent._StatusSeed = 6;
// Torrent.fields.seedRatioMode
Torrent._RatioUseGlobal = 0;
Torrent._RatioUseLocal = 1;
Torrent._RatioUnlimited = 2;
// Torrent.fields.error
Torrent._ErrNone = 0;
Torrent._ErrTrackerWarning = 1;
Torrent._ErrTrackerError = 2;
Torrent._ErrLocalError = 3;
// TrackerStats' announceState
Torrent._TrackerInactive = 0;
Torrent._TrackerWaiting = 1;
Torrent._TrackerQueued = 2;
Torrent._TrackerActive = 3;
Torrent.Fields = {};
// commonly used fields which only need to be loaded once,
// either on startup or when a magnet finishes downloading its metadata
// finishes downloading its metadata
Torrent.Fields.Metadata = [
'addedDate',
'file-count',
'name',
'primary-mime-type',
'totalSize',
];
// commonly used fields which need to be periodically refreshed
Torrent.Fields.Stats = [
'error',
'errorString',
'eta',
'isFinished',
'isStalled',
'labels',
'leftUntilDone',
'metadataPercentComplete',
'peersConnected',
'peersGettingFromUs',
'peersSendingToUs',
'percentDone',
'queuePosition',
'rateDownload',
'rateUpload',
'recheckProgress',
'seedRatioMode',
'seedRatioLimit',
'sizeWhenDone',
'status',
'trackers',
'downloadDir',
'uploadedEver',
'uploadRatio',
'webseedsSendingToUs',
];
// fields used by the inspector which only need to be loaded once
Torrent.Fields.InfoExtra = [
'comment',
'creator',
'dateCreated',
'files',
'hashString',
'isPrivate',
'magnetLink',
'pieceCount',
'pieceSize',
];
// fields used in the inspector which need to be periodically refreshed
Torrent.Fields.StatsExtra = [
'activityDate',
'corruptEver',
'desiredAvailable',
'downloadedEver',
'fileStats',
'haveUnchecked',
'haveValid',
'peers',
'startDate',
'trackerStats',
];