ytstats 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/LICENSE +28 -0
- package/README.md +256 -0
- package/bin/ytstats.js +9 -0
- package/docs/architecture.md +128 -0
- package/docs/auth.md +186 -0
- package/docs/cli.md +285 -0
- package/docs/configuration.md +127 -0
- package/docs/contributing.md +155 -0
- package/docs/gotchas/auth.md +121 -0
- package/docs/gotchas/cli-output.md +155 -0
- package/docs/gotchas/config-storage.md +98 -0
- package/docs/gotchas/youtube-api.md +132 -0
- package/docs/gotchas.md +10 -0
- package/docs/output-contract.md +214 -0
- package/docs/testing.md +93 -0
- package/docs/youtube-apis.md +180 -0
- package/package.json +66 -0
- package/src/api/analytics.js +259 -0
- package/src/api/client.js +40 -0
- package/src/api/data.js +65 -0
- package/src/api/reporting.js +100 -0
- package/src/api/transforms.js +170 -0
- package/src/auth/credentials.js +172 -0
- package/src/auth/oauth.js +167 -0
- package/src/auth/session.js +257 -0
- package/src/auth/tokens.js +140 -0
- package/src/cli.js +600 -0
- package/src/config/paths.js +47 -0
- package/src/config/store.js +123 -0
- package/src/dates.js +73 -0
- package/src/diagnostics.js +857 -0
- package/src/errors.js +233 -0
- package/src/fetch-all.js +181 -0
- package/src/index.js +44 -0
- package/src/output.js +168 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { readJson, writeJson, removeFile } from '../config/store.js';
|
|
3
|
+
import { TOKENS_FILE } from '../config/paths.js';
|
|
4
|
+
import { YtStatsError, ERROR_CODES } from '../errors.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* tokens.json shape:
|
|
8
|
+
* {
|
|
9
|
+
* version: 1,
|
|
10
|
+
* default: "UC...",
|
|
11
|
+
* accounts: { "UC...": { channelId, channelTitle, customUrl, tokens, savedAt } }
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* Keyed by channel so one machine can hold several channels; `default` is what
|
|
15
|
+
* commands use when --account is not given.
|
|
16
|
+
*/
|
|
17
|
+
function emptyStore() {
|
|
18
|
+
return { version: 1, default: null, accounts: {} };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function read() {
|
|
22
|
+
const raw = readJson(TOKENS_FILE);
|
|
23
|
+
if (!raw || typeof raw !== 'object' || !raw.accounts) return emptyStore();
|
|
24
|
+
return { ...emptyStore(), ...raw };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function write(store) {
|
|
28
|
+
writeJson(TOKENS_FILE, store);
|
|
29
|
+
return store;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Persist (or update) one channel's tokens. First account logged in wins the default. */
|
|
33
|
+
export function saveAccount({ channelId, channelTitle, customUrl, tokens }) {
|
|
34
|
+
if (!channelId) {
|
|
35
|
+
throw new YtStatsError('Cannot save credentials without a channel id.', {
|
|
36
|
+
code: ERROR_CODES.AUTH_FAILED,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const store = read();
|
|
41
|
+
const existing = store.accounts[channelId];
|
|
42
|
+
|
|
43
|
+
store.accounts[channelId] = {
|
|
44
|
+
channelId,
|
|
45
|
+
channelTitle: channelTitle ?? existing?.channelTitle ?? null,
|
|
46
|
+
customUrl: customUrl ?? existing?.customUrl ?? null,
|
|
47
|
+
// A refresh happens without a new refresh_token; keep the one we already hold.
|
|
48
|
+
tokens: { ...(existing?.tokens ?? {}), ...tokens },
|
|
49
|
+
savedAt: new Date().toISOString(),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
if (!store.default || !store.accounts[store.default]) store.default = channelId;
|
|
53
|
+
write(store);
|
|
54
|
+
return store.accounts[channelId];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Look up an account by channel id or @handle. With no selector, returns the
|
|
59
|
+
* default account. An unknown selector returns null — never a silent fallback to
|
|
60
|
+
* the default, which would query the wrong channel.
|
|
61
|
+
*/
|
|
62
|
+
export function loadAccount(selector) {
|
|
63
|
+
const store = read();
|
|
64
|
+
|
|
65
|
+
if (!selector) {
|
|
66
|
+
return store.default ? store.accounts[store.default] ?? null : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (store.accounts[selector]) return store.accounts[selector];
|
|
70
|
+
|
|
71
|
+
const wanted = String(selector).toLowerCase();
|
|
72
|
+
const match = Object.values(store.accounts).find(
|
|
73
|
+
a => a.customUrl?.toLowerCase() === wanted || a.channelTitle?.toLowerCase() === wanted,
|
|
74
|
+
);
|
|
75
|
+
return match ?? null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Accounts without token material — safe to print. */
|
|
79
|
+
export function listAccounts() {
|
|
80
|
+
const store = read();
|
|
81
|
+
return Object.values(store.accounts).map(a => ({
|
|
82
|
+
channelId: a.channelId,
|
|
83
|
+
channelTitle: a.channelTitle,
|
|
84
|
+
customUrl: a.customUrl,
|
|
85
|
+
savedAt: a.savedAt,
|
|
86
|
+
isDefault: a.channelId === store.default,
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function setDefaultAccount(channelId) {
|
|
91
|
+
const store = read();
|
|
92
|
+
if (!store.accounts[channelId]) {
|
|
93
|
+
throw new YtStatsError(`Not logged in to channel ${channelId}.`, {
|
|
94
|
+
code: ERROR_CODES.NOT_AUTHENTICATED,
|
|
95
|
+
hint: 'Run `ytstats status` to see which channels are available.',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
store.default = channelId;
|
|
99
|
+
write(store);
|
|
100
|
+
return store.accounts[channelId];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Remove one account, promoting another to default if needed. */
|
|
104
|
+
export function removeAccount(channelId) {
|
|
105
|
+
const store = read();
|
|
106
|
+
if (!store.accounts[channelId]) return false;
|
|
107
|
+
|
|
108
|
+
delete store.accounts[channelId];
|
|
109
|
+
if (store.default === channelId) {
|
|
110
|
+
store.default = Object.keys(store.accounts)[0] ?? null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (Object.keys(store.accounts).length === 0) removeFile(TOKENS_FILE);
|
|
114
|
+
else write(store);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function clearAllAccounts() {
|
|
119
|
+
return removeFile(TOKENS_FILE);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* One-time import of the pre-1.0 per-project token file (.yta/tokens.json).
|
|
124
|
+
* Never overwrites an account that already exists.
|
|
125
|
+
*/
|
|
126
|
+
export function migrateLegacyTokens(legacyPath, { channelId, channelTitle, customUrl } = {}) {
|
|
127
|
+
let legacy;
|
|
128
|
+
try {
|
|
129
|
+
legacy = JSON.parse(fs.readFileSync(legacyPath, 'utf-8'));
|
|
130
|
+
} catch {
|
|
131
|
+
return { migrated: false, reason: 'no-legacy-file' };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!legacy?.refresh_token) return { migrated: false, reason: 'no-refresh-token' };
|
|
135
|
+
if (!channelId) return { migrated: false, reason: 'unknown-channel' };
|
|
136
|
+
if (loadAccount(channelId)) return { migrated: false, reason: 'already-logged-in' };
|
|
137
|
+
|
|
138
|
+
saveAccount({ channelId, channelTitle, customUrl, tokens: legacy });
|
|
139
|
+
return { migrated: true, channelId };
|
|
140
|
+
}
|