Files
pastilhasandClaude Opus 5 864dd16355 transmission, extracted from the platform into its own repository
The sixth plugin out, immediately after photos. A sidecar, a proxy router and
29 web files; no db/ directory, because there are no tables.

── The one thing that is not self-contained ──

The connection lives in `service_connections`, a CORE table, reached through
getServiceConnection/saveServiceConnection/getServiceCredentials. This plugin
declares no schema.

Deliberate: that table is the app store's shared credential store, keyed on
(user_id, service), already encrypted at rest and already read by
app-store/effects.ts. A private copy of the same three columns would be a
second place the same fact could live, and the runbook is explicit that a move
plus a data-format change gives any failure two causes.

The cost, stated rather than hidden: this plugin depends on a core facility it
does not own, and would break if that table changed shape. Photos, by contrast,
owns photos_config outright.

── 38 palette classes paid down, mapped by meaning ──

  blue     downloading / download speed  -> info
  cyan     checking (verifying)          -> busy
  emerald  seeding / complete            -> success
  amber    queued / stalled / warnings   -> warning

Collapsing blue and cyan into one tone would have lost a real distinction in
the status dot, so `checking` went to `busy` — in-progress work, which is what
that tone is for.

── A near-miss worth recording ──

The first copy globbed *.tsx and *.ts and silently missed detail/ and dialogs/
— ten components. tsgo caught it as ten "cannot find module" errors. Worth
stating because it is quiet in the other direction: had those been imported
lazily, the typecheck would have passed and the panels would have broken at
runtime. Check the directory tree, not the file glob.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:19:30 +00:00

245 lines
6.0 KiB
TypeScript

// Wire types for the Transmission RPC surface this sidecar uses.
//
// Field names are Transmission's OWN, verbatim — including its inconsistent casing (`percentDone` next to
// `begin_piece`, `peer-limit`, `sequential_download`). That is deliberate: renaming them would mean every
// future field needs a translation entry, and a name that doesn't appear in Transmission's rpc-spec.md is a
// name you cannot grep for when something misbehaves. The only fields we invent are the `officer*` derived
// ones at the bottom of Torrent, which are prefixed so they can never be mistaken for upstream data.
/** Transmission's torrent status enum (rpc-spec.md §3.3). */
export const TorrentStatus = {
Stopped: 0,
CheckWait: 1,
Check: 2,
DownloadWait: 3,
Download: 4,
SeedWait: 5,
Seed: 6,
} as const;
export type TrackerStat = {
announce: string;
announceState: number;
downloadCount: number;
hasAnnounced: boolean;
hasScraped: boolean;
host: string;
id: number;
isBackup: boolean;
lastAnnouncePeerCount: number;
lastAnnounceResult: string;
lastAnnounceSucceeded: boolean;
lastAnnounceTime: number;
lastScrapeResult: string;
lastScrapeSucceeded: boolean;
lastScrapeTime: number;
leecherCount: number;
nextAnnounceTime: number;
scrape: string;
seederCount: number;
tier: number;
};
export type TorrentFile = {
bytesCompleted: number;
length: number;
name: string;
};
export type TorrentFileStat = {
bytesCompleted: number;
priority: number;
wanted: boolean;
};
export type TorrentPeer = {
address: string;
clientName: string;
clientIsChoked: boolean;
clientIsInterested: boolean;
flagStr: string;
isDownloadingFrom: boolean;
isEncrypted: boolean;
isIncoming: boolean;
isUploadingTo: boolean;
isUTP: boolean;
peerIsChoked: boolean;
peerIsInterested: boolean;
port: number;
progress: number;
rateToClient: number;
rateToPeer: number;
};
export type PeersFrom = {
fromCache: number;
fromDht: number;
fromIncoming: number;
fromLpd: number;
fromLtep: number;
fromPex: number;
fromTracker: number;
};
export type Torrent = {
id: number;
name: string;
status: number;
totalSize: number;
sizeWhenDone: number;
leftUntilDone: number;
haveValid: number;
percentDone: number;
metadataPercentComplete: number;
recheckProgress?: number;
downloadedEver: number;
uploadedEver: number;
corruptEver?: number;
uploadRatio: number;
rateDownload: number;
rateUpload: number;
eta: number;
peersSendingToUs: number;
peersGettingFromUs: number;
addedDate: number;
doneDate: number;
activityDate: number;
secondsSeeding: number;
downloadDir: string;
bandwidthPriority: number;
queuePosition: number;
isPrivate: boolean;
labels: string[];
error: number;
errorString: string;
pieceCount: number;
pieceSize: number;
magnetLink: string;
group: string;
'file-count': number;
trackerStats: TrackerStat[];
trackerList?: string;
// Per-torrent limits — the fields the "Other settings" dialog reads and writes.
honorsSessionLimits: boolean;
downloadLimited: boolean;
downloadLimit: number;
uploadLimited: boolean;
uploadLimit: number;
'peer-limit': number;
seedRatioMode: number;
seedRatioLimit: number;
seedIdleMode: number;
seedIdleLimit: number;
sequential_download?: boolean;
// Detail-only fields — present when fetched through /_officer/torrents/:id.
hashString?: string;
comment?: string;
creator?: string;
dateCreated?: number;
maxConnectedPeers?: number;
files?: TorrentFile[];
fileStats?: TorrentFileStat[];
peers?: TorrentPeer[];
peersFrom?: PeersFrom;
// ── Derived by the sidecar, never sent by Transmission ──
/**
* The primary tracker's host with its port and any ignorable subdomain prefix stripped
* (`udp://tr.example.org:1337/announce` → `example.org`). The UI groups by this, so the normalisation
* has to be one implementation, not one per caller.
*/
officerTrackerHost: string;
/** Human-readable announce state of the primary tracker, for the "Tracker Status" column. */
officerTrackerStatus: string;
/** Count of trackers that have reported an error, so the UI can flag a torrent without walking the array. */
officerTrackerErrors: number;
};
export type SessionSettings = Record<string, unknown> & {
version: string;
'rpc-version': number;
'download-dir': string;
};
export type SessionStats = {
activeTorrentCount: number;
pausedTorrentCount: number;
torrentCount: number;
downloadSpeed: number;
uploadSpeed: number;
'cumulative-stats': StatsBucket;
'current-stats': StatsBucket;
};
export type StatsBucket = {
downloadedBytes: number;
uploadedBytes: number;
filesAdded: number;
sessionCount: number;
secondsActive: number;
};
/** The fields the torrent list needs. Mirrors what the list UI actually renders — nothing speculative. */
export const LIST_FIELDS = [
'activityDate',
'addedDate',
'bandwidthPriority',
'corruptEver',
'doneDate',
'downloadDir',
'downloadedEver',
'downloadLimit',
'downloadLimited',
'error',
'errorString',
'eta',
'file-count',
'group',
'haveValid',
'honorsSessionLimits',
'id',
'isPrivate',
'labels',
'leftUntilDone',
'magnetLink',
'metadataPercentComplete',
'name',
'peer-limit',
'peersGettingFromUs',
'peersSendingToUs',
'percentDone',
'pieceCount',
'pieceSize',
'queuePosition',
'rateDownload',
'rateUpload',
'secondsSeeding',
'seedIdleLimit',
'seedIdleMode',
'seedRatioLimit',
'seedRatioMode',
'sequential_download',
'sizeWhenDone',
'status',
'totalSize',
'trackerList',
'trackerStats',
'uploadedEver',
'uploadLimit',
'uploadLimited',
'uploadRatio',
] as const;
/** Extra fields only the detail pane needs — big arrays we refuse to pull for every row on every poll. */
export const DETAIL_FIELDS = [
'comment',
'creator',
'dateCreated',
'fileStats',
'files',
'hashString',
'maxConnectedPeers',
'peers',
'peersFrom',
'recheckProgress',
] as const;