/* @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', ];