677 lines
16 KiB
JavaScript
677 lines
16 KiB
JavaScript
/**
|
|
* @license
|
|
*
|
|
* This file Copyright (C) 2020 Mnemosyne LLC
|
|
*
|
|
* It may be used under the GNU GPL versions 2 or 3
|
|
* or any future license endorsed by Mnemosyne LLC.
|
|
*/
|
|
|
|
import { Formatter } from './formatter.js';
|
|
import { Prefs } from './prefs.js';
|
|
import { deepEqual } from './utils.js';
|
|
|
|
/// DOMAINS
|
|
|
|
// example: "tracker.ubuntu.com" returns "ubuntu.com"
|
|
function getDomainName(host) {
|
|
const dot = host.indexOf('.');
|
|
if (dot !== host.lastIndexOf('.')) {
|
|
host = host.slice(dot + 1);
|
|
}
|
|
return host;
|
|
}
|
|
|
|
// example: "ubuntu.com" returns "Ubuntu"
|
|
function getReadableDomain(name) {
|
|
if (name.length > 0) {
|
|
name = name.charAt(0).toUpperCase() + name.slice(1);
|
|
}
|
|
const dot = name.indexOf('.');
|
|
if (dot !== -1) {
|
|
name = name.slice(0, dot);
|
|
}
|
|
return name;
|
|
}
|
|
|
|
// key: url string
|
|
// val: { domain, readable_domain }
|
|
const announce_to_domain_cache = {};
|
|
|
|
function getAnnounceDomain(announce) {
|
|
if (announce_to_domain_cache[announce]) {
|
|
return announce_to_domain_cache[announce];
|
|
}
|
|
|
|
const url = new URL(announce);
|
|
const domain = getDomainName(url.host);
|
|
const name = getReadableDomain(domain);
|
|
const o = { domain, name, url };
|
|
announce_to_domain_cache[announce] = o;
|
|
return o;
|
|
}
|
|
|
|
///
|
|
|
|
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;
|
|
}
|
|
getLastActivity() {
|
|
return this.fields.activityDate;
|
|
}
|
|
getLeftUntilDone() {
|
|
return this.fields.leftUntilDone;
|
|
}
|
|
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() {
|
|
const trackers = this.fields.trackers || [];
|
|
for (const tracker of trackers) {
|
|
if (tracker.announce && !tracker.domain) {
|
|
Object.assign(tracker, getAnnounceDomain(tracker.announce));
|
|
}
|
|
}
|
|
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 filter one of Prefs.Filter*
|
|
* @param search substring to look for, or null
|
|
* @return true if it passes the test, false if it fails
|
|
*/
|
|
test(state, search, tracker) {
|
|
// flter by state...
|
|
let pass = this.testState(state);
|
|
|
|
// maybe filter by text...
|
|
if (pass && search && search.length > 0) {
|
|
pass = this.getCollatedName().includes(search.toLowerCase());
|
|
}
|
|
|
|
// 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',
|
|
'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',
|
|
'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',
|
|
];
|