Greasy Fork

来自缓存

bangumi-episode-ratings-gadget

Bangumi 单集评分的超合金组件

// ==UserScript==
// @namespace   https://github.com/umajho
// @name        bangumi-episode-ratings-gadget
// @version     0.6.0
// @description Bangumi 单集评分的超合金组件
// @license     MIT
// @website     https://github.com/umajho/bangumi-episode-ratings
// @match       https://bangumi.tv/*
// @match       https://bgm.tv/*
// @match       https://chii.in/*
// @grant       GM_info
// @grant       unsafeWindow
// @grant       window.close
// ==/UserScript==

(function() {

"use strict";

//#region src/env.ts
const env = {
	get APP_AUTH_ENTRYPOINT() {
		return "https://bgm-ep-ratings.deno.dev/auth/";
	},
	get APP_API_ENTRYPOINT() {
		const debugURL = localStorage.getItem(this.LOCAL_STORAGE_KEY_DEBUG_API_ENTRYPOINT_URL);
		if (debugURL) return debugURL;
		return "https://xn--kbrs5al25jbhj.bgm.zone/api/";
	},
	LOCAL_STORAGE_KEY_DEBUG_API_ENTRYPOINT_URL: "bgm_ep_ratings_debug_api_entrypoint_url",
	LOCAL_STORAGE_KEY_TOKEN: "bgm_ep_ratings_token",
	LOCAL_STORAGE_KEY_JWT: "bgm_ep_ratings_jwt",
	SEARCH_PARAMS_KEY_TOKEN_COUPON: "bgm_ep_ratings_token_coupon"
};
var env_default = env;

//#endregion
//#region package.json
const version = "0.6.0";

//#endregion
//#region src/bangumi-client.ts
class BangumiClient {
	episodeCache = {};
	putEntryIntoEpisodeCache(episodeID, entry) {
		this.episodeCache[episodeID] = entry;
	}
	async getEpisodeTitle(episodeID) {
		const cacheEntry = await (this.episodeCache[episodeID] ??= new Promise(async (resolve) => {
			const episode = await this.getEpisode(episodeID);
			if (!episodeID) {
				resolve(null);
			} else {
				resolve({
					name: episode.name,
					sort: episode.sort
				});
			}
		}));
		if (!cacheEntry) return `获取失败(ID:${episodeID})`;
		return `ep.${cacheEntry.sort} ${cacheEntry.name}`;
	}
	episodeResponseCache = {};
	async getEpisode(episodeID) {
		return this.episodeResponseCache[episodeID] ??= new Promise(async (resolve) => {
			const path = `/v0/episodes/${episodeID}`;
			const resp = await this.fetchAPI(path, { method: "GET" });
			if (resp[0] === "error") {
				resolve(null);
			} else {
				resolve(resp[1]);
			}
		});
	}
	async fetchAPI(path, opts) {
		const url = new URL(path, "https://api.bgm.tv");
		if (opts.searchParams) {
			url.search = opts.searchParams.toString();
		}
		const resp = await fetch(url.toString(), {
			method: opts.method,
			...opts.body && { body: opts.body }
		});
		if (!resp.ok) {
			console.warn("调用 bangumi API 失败", await resp.text());
			return ["error"];
		}
		return ["ok", await resp.json()];
	}
}

//#endregion
//#region ../app/src/shared/endpoint-paths.ts
var endpoint_paths_default = {
	CORS_PREFLIGHT_BYPASS: "cors-preflight-bypass",
	AUTH: {
		BANGUMI_PAGE: "bangumi-page",
		CALLBACK: "callback",
		REDEEM_TOKEN_COUPON: "redeem-token-coupon",
		REFRESH_JWT: "refresh-jwt"
	}
};

//#endregion
//#region src/client.ts
class Client {
	authEntrypoint;
	apiEntrypoint;
	token;
	constructor(opts) {
		this.authEntrypoint = opts.authEntrypoint;
		this.apiEntrypoint = opts.apiEntrypoint;
		this.token = opts.token;
	}
	get URL_AUTH_BANGUMI_PAGE() {
		const url = new URL(this.buildFullEndpoint("auth", endpoint_paths_default.AUTH.BANGUMI_PAGE));
		url.searchParams.set("gadget_version", global_default.version);
		url.searchParams.set("referrer", window.location.origin);
		return url.toString();
	}
	async redeemTokenCoupon(tokenCoupon) {
		const resp = await this.fetch("auth", endpoint_paths_default.AUTH.REDEEM_TOKEN_COUPON, {
			tokenType: "basic",
			method: "POST",
			body: JSON.stringify({ tokenCoupon })
		});
		if (resp[0] === "auth_required") throw new Error("unreachable!");
		return resp;
	}
	async rateEpisode(opts) {
		if (!this.token) return ["auth_required"];
		if (opts.score !== null) {
			const bodyData = { score: opts.score };
			return await this.fetch("api/v1", `subjects/${opts.subjectID}/episodes/${opts.episodeID}/ratings/mine`, {
				tokenType: "jwt",
				method: "PUT",
				body: JSON.stringify(bodyData)
			});
		} else {
			return await this.fetch("api/v1", `subjects/${opts.subjectID}/episodes/${opts.episodeID}/ratings/mine`, {
				tokenType: "jwt",
				method: "DELETE"
			});
		}
	}
	subjectEpisodesRatingsCache = {};
	hasCachedSubjectEpisodesRatings(subjectID) {
		return !!this.subjectEpisodesRatingsCache[subjectID];
	}
	async getSubjectEpisodesRatings(opts) {
		if (this.subjectEpisodesRatingsCache[opts.subjectID]) {
			const cached = this.subjectEpisodesRatingsCache[opts.subjectID];
			if ("then" in cached) {
				return await cached;
			} else {
				return ["ok", cached];
			}
		}
		return this.subjectEpisodesRatingsCache[opts.subjectID] = this.fetch("api/v1", `subjects/${opts.subjectID}/episodes/ratings`, {
			tokenType: "jwt",
			method: "GET"
		}).then((resp) => {
			if (resp[0] === "auth_required") {
				throw new Error("unreachable!");
			} else if (resp[0] === "error") {
				delete this.subjectEpisodesRatingsCache[opts.subjectID];
				return resp;
			} else if (resp[0] === "ok") {
				const [_, data] = resp;
				return ["ok", this.subjectEpisodesRatingsCache[opts.subjectID] = data];
			} else {
				resp;
				throw new Error("unreachable!");
			}
		});
	}
	async getEpisodeRatings() {
		return await this.fetch("api/v1", `subjects/${global_default.subjectID}/episodes/${global_default.episodeID}/ratings`, {
			tokenType: "jwt",
			method: "GET"
		});
	}
	async getMyEpisodeRating() {
		return await this.fetch("api/v1", `subjects/${global_default.subjectID}/episodes/${global_default.episodeID}/ratings/mine`, {
			tokenType: "jwt",
			method: "GET"
		});
	}
	async changeUserEpisodeRatingVisibility(opts) {
		return await this.fetch("api/v1", `subjects/${global_default.subjectID}/episodes/${global_default.episodeID}/ratings/mine/is-visible`, {
			tokenType: "jwt",
			method: "PUT",
			body: JSON.stringify(opts.isVisible)
		});
	}
	get TIMELINE_ITEMS_PER_PAGE() {
		return 10;
	}
	async getMyTimelineItems(opts) {
		const searchParams = new URLSearchParams();
		searchParams.set("offset", "" + (opts.pageNumber - 1) * 10);
		searchParams.set("limit", "" + this.TIMELINE_ITEMS_PER_PAGE);
		return await this.fetch("api/v1", `users/me/timeline/items`, {
			tokenType: "jwt",
			method: "GET",
			searchParams
		});
	}
	async deleteMyTimelineItem(opts) {
		return await this.fetch("api/v1", `users/me/timeline/items/${opts.timestampMs}`, {
			tokenType: "jwt",
			method: "DELETE"
		});
	}
	async downloadMyEpisodeRatingsData() {
		const resp = await this.fetch("api/v1", "users/me/episode-ratings-data-file", {
			tokenType: "jwt",
			method: "GET"
		});
		if (resp[0] !== "ok") return resp;
		const [_, data] = resp;
		this.saveFile(data.content, { fileName: data.fileName });
		return ["ok", undefined];
	}
	async fetch(group, endpointPath, opts) {
		const url = new URL(this.buildFullEndpoint(group, endpointPath));
		if (opts.searchParams) {
			url.search = opts.searchParams.toString();
		}
		const headers = new Headers();
		if (this.token) {
			if (opts.tokenType === "basic") {
				headers.set("Authorization", `Basic ${this.token}`);
			} else {
				const resp = await this.fetchJWT();
				if (resp[0] !== "ok") return resp;
				const [_, jwt] = resp;
				headers.set("Authorization", `Bearer ${jwt}`);
			}
		}
		headers.set("X-Gadget-Version", global_default.version);
		if (global_default.claimedUserID !== null) {
			headers.set("X-Claimed-User-ID", global_default.claimedUserID.toString());
		}
		try {
			const resp = await fetch(this.buildRequest(url, {
				method: opts.method,
				headers,
				body: opts.body
			}, { shouldBypassCORSPreflight: group === "api/v1" }));
			const respJSON = await resp.json();
			if (respJSON[0] === "error" && respJSON[1] === "AUTH_REQUIRED") {
				if (global_default.token.getValueOnce() !== null) {
					global_default.token.setValue(null);
				}
				return ["auth_required"];
			}
			return respJSON;
		} catch (e) {
			const operation = `fetch \`${opts.method} ${url}\``;
			console.error(`${operation} 失败`, e);
			return ["error", "UNKNOWN", `${operation} 失败: ${e}`];
		}
	}
	buildRequest(url, init, opts) {
		if (opts.shouldBypassCORSPreflight) {
			url.pathname = `/${endpoint_paths_default.CORS_PREFLIGHT_BYPASS}/${init.method}${url.pathname}`;
			const body = [Object.fromEntries(init.headers.entries()), init.body ?? null,];
			return new Request(url, {
				method: "POST",
				body: JSON.stringify(body)
			});
		} else {
			return new Request(url, init);
		}
	}
	buildFullEndpoint(group, endpointPath) {
		const entrypoint = (() => {
			switch (group) {
				case "auth": return this.authEntrypoint;
				case "api/v1": return this.apiEntrypoint + "v1/";
				default:
					group;
					throw new Error("unreachable");
			}
		})();
		return join(entrypoint, endpointPath);
	}
	async fetchJWT() {
		const fn = async () => {
			const localToken = localStorage.getItem(env_default.LOCAL_STORAGE_KEY_JWT);
			if (localToken && checkJWTExpiry(localToken) === "valid") {
				return ["ok", localToken];
			}
			const resp = await this.fetch("auth", endpoint_paths_default.AUTH.REFRESH_JWT, {
				tokenType: "basic",
				method: "POST"
			});
			if (resp[0] === "ok") {
				const [_, jwt] = resp;
				localStorage.setItem(env_default.LOCAL_STORAGE_KEY_JWT, jwt);
			}
			return resp;
		};
		if (window.navigator.locks) {
			return window.navigator.locks.request(env_default.LOCAL_STORAGE_KEY_JWT, fn);
		} else {
			return fn();
		}
	}
	saveFile(data, opts) {
		const blob = new Blob([data], { type: "text/plain; charset=utf-8" });
		const aEl = document.createElement("a");
		aEl.href = URL.createObjectURL(blob);
		aEl.download = opts.fileName;
		aEl.click();
		URL.revokeObjectURL(aEl.href);
	}
	clearCache() {
		this.subjectEpisodesRatingsCache = {};
	}
}
function join(base, url) {
	return new URL(url, base).href;
}
function checkJWTExpiry(jwt) {
	const decoded = JSON.parse(atob(jwt.split(".")[1]));
	const exp = decoded.exp;
	const now = Math.floor(Date.now() / 1000);
	return now > exp ? "expired" : "valid";
}

//#endregion
//#region src/utils/watched.ts
class Watched {
	_watchers = [];
	_shouldDeduplicateShallowly;
	_broadcastID;
	constructor(_value, opts) {
		this._value = _value;
		this._shouldDeduplicateShallowly = opts?.shouldDeduplicateShallowly ?? false;
		this._broadcastID = opts?.broadcastID ?? null;
		if (this._broadcastID) {
			window.addEventListener("storage", (ev) => {
				if (ev.key === this._broadcastID && ev.newValue) {
					const oldValue = this._value;
					const newValue = JSON.parse(ev.newValue);
					this._value = newValue;
					this._watchers.forEach((w) => w(newValue, oldValue));
				}
			});
		}
	}
	getValueOnce() {
		return this._value;
	}
	setValue(newValue) {
		const oldValue = this._value;
		if (this._shouldDeduplicateShallowly && oldValue === newValue) {
			return;
		}
		this._value = newValue;
		this._watchers.forEach((w) => w(newValue, oldValue));
		if (this._broadcastID) {
			localStorage.setItem(this._broadcastID, JSON.stringify(newValue));
			localStorage.removeItem(this._broadcastID);
		}
	}
	watchDeferred(cb) {
		this._watchers.push(cb);
		return () => {
			this._watchers = this._watchers.filter((w) => w !== cb);
		};
	}
	watch(cb) {
		cb(this._value, undefined);
		return this.watchDeferred(cb);
	}
	createComputed(computeFn) {
		const computed = new Watched(computeFn(this.getValueOnce()));
		this.watchDeferred((newValue) => {
			computed.setValue(computeFn(newValue));
		});
		return computed;
	}
}

//#endregion
//#region src/global.ts
const global = {};
var global_default = global;
function initializeGlobal() {
	Object.assign(global, makeGlobal());
	((window.unsafeWindow ?? window).__bgm_ep_ratings__debug ??= {}).Global = global;
}
function makeGlobal() {
	const { subjectID, episodeID } = (() => {
		let subjectID$1 = null;
		let episodeID$1 = null;
		const pathParts = window.location.pathname.split("/").filter(Boolean);
		if (pathParts[0] === "subject") {
			subjectID$1 = Number(pathParts[1]);
		} else if (pathParts.length === 2 && pathParts[0] === "ep") {
			episodeID$1 = Number(pathParts[1]);
			const subjectHref = $("#headerSubject > .nameSingle > a").attr("href");
			subjectID$1 = Number(subjectHref.split("/")[2]);
		}
		return {
			subjectID: subjectID$1,
			episodeID: episodeID$1
		};
	})();
	const claimedUserID = (() => {
		if ("unsafeWindow" in window) {
			return window.unsafeWindow.CHOBITS_UID || null;
		}
		return window.CHOBITS_UID || null;
	})();
	if (claimedUserID === null) {
		localStorage.removeItem(env_default.LOCAL_STORAGE_KEY_TOKEN);
	}
	const meAEl = $("#dock .content .first > a");
	const claimedUserTextID = meAEl.attr("href")?.split("/")?.at(-1) ?? null;
	const claimedUserName = meAEl.text().trim() ?? null;
	const token = new Watched(localStorage.getItem(env_default.LOCAL_STORAGE_KEY_TOKEN));
	window.addEventListener("storage", (ev) => {
		if (ev.key !== env_default.LOCAL_STORAGE_KEY_TOKEN) return;
		if (ev.newValue === token.getValueOnce()) return;
		token.setValue(ev.newValue);
	});
	const client = new Client({
		authEntrypoint: env_default.APP_AUTH_ENTRYPOINT,
		apiEntrypoint: env_default.APP_API_ENTRYPOINT,
		token: token.getValueOnce()
	});
	const bangumiClient = new BangumiClient();
	token.watchDeferred((newToken) => {
		if (newToken) {
			localStorage.setItem(env_default.LOCAL_STORAGE_KEY_TOKEN, newToken);
		} else {
			localStorage.removeItem(env_default.LOCAL_STORAGE_KEY_TOKEN);
			localStorage.removeItem(env_default.LOCAL_STORAGE_KEY_JWT);
		}
		client.token = newToken;
		client.clearCache();
	});
	const currentEpisodeVisibilityFromServer = new Watched(null, { broadcastID: `bgm_ep_ratings::broadcasts::${episodeID}::visibility` });
	function updateCurrentEpisodeVisibilityFromServerRaw(raw) {
		if (!raw) {
			currentEpisodeVisibilityFromServer.setValue(null);
		} else {
			currentEpisodeVisibilityFromServer.setValue({ isVisible: raw.is_visible });
		}
	}
	return {
		version,
		subjectID,
		episodeID,
		claimedUserID,
		claimedUserTextID,
		claimedUserName,
		token,
		client,
		bangumiClient,
		currentEpisodeVisibilityFromServer,
		updateCurrentEpisodeVisibilityFromServerRaw
	};
}

//#endregion
//#region src/definitions.ts
const scores = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
function describeScore(score) {
	return [[9.5, "超神作"], [8.5, "神作"], [7.5, "力荐"], [6.5, "推荐"], [5.5, "还行"], [4.5, "不过不失"], [3.5, "较差"], [2.5, "差"], [1.5, "很差"],].find(([min, _]) => score >= min)?.[1] ?? "不忍直视";
}
function describeScoreEx(score) {
	let description = `${describeScore(score)} ${score}`;
	if (score === 1 || score === 10) {
		description += " (请谨慎评价)";
	}
	return description;
}

//#endregion
//#region src/components/Stars.ts
function renderStars(el, props) {
	el = $(`
    <div class="stars-container">
      <div class="rating-cancel"><a title="Cancel Rating"></a></div>
    </div>
  `).replaceAll(el);
	for (const score of scores) {
		const starEl = $(`
      <div class="star-rating">
        <a></a>
      </div>
    `);
		el.append(starEl);
		const aEl = starEl.find("a");
		aEl.text(score);
		aEl.attr("title", describeScoreEx(score));
		starEl.on("mouseover", () => props.hoveredScore.setValue(score)).on("mouseout", () => props.hoveredScore.setValue(null)).on("click", () => props.onRateEpisode(score));
	}
	function updateStarsContainer(params) {
		if (params[0] === "invisible") {
			el.css("display", "none");
			return;
		}
		el.css("display", "");
		const [_, { ratedScore, hoveredScore }] = params;
		const isHovering = hoveredScore !== null;
		const maxScoreToHighlight = hoveredScore ?? ratedScore ?? null;
		{
			let alarmScore = maxScoreToHighlight;
			if (alarmScore === "cancel") {
				alarmScore = ratedScore;
			}
			props.onUpdateScoreToAlarm(alarmScore);
		}
		const starEls = el.find(".star-rating");
		for (const score of scores) {
			const starEl = starEls.eq(score - 1);
			starEl.removeClass("star-rating-on").removeClass("star-rating-hover");
			if (typeof maxScoreToHighlight === "number" && score <= maxScoreToHighlight) {
				starEl.addClass(isHovering ? "star-rating-hover" : "star-rating-on");
			}
		}
		$(el).find(".rating-cancel").removeClass("star-rating-hover");
		if (hoveredScore === "cancel") {
			$(el).find(".rating-cancel").addClass("star-rating-hover");
		}
	}
	return { updateStarsContainer };
}

//#endregion
//#region src/models/VotesData.ts
class VotesData {
	constructor(data) {
		this.data = data;
	}
	getClonedData() {
		return { ...this.data };
	}
	getScoreVotes(score) {
		return this.data[score] ?? 0;
	}
	totalVotesCache = null;
	get totalVotes() {
		if (this.totalVotesCache) return this.totalVotesCache;
		let totalVotes = 0;
		for (const score of scores) {
			totalVotes += this.getScoreVotes(score);
		}
		return this.totalVotesCache = totalVotes;
	}
	averageScoreCache = null;
	get averageScore() {
		if (this.averageScoreCache) return this.averageScoreCache;
		let totalScore = 0;
		for (const score of scores) {
			totalScore += this.getScoreVotes(score) * score;
		}
		return this.averageScoreCache = totalScore / this.totalVotes;
	}
	mostVotedScoreCache = null;
	get mostVotedScore() {
		if (this.mostVotedScoreCache) return this.mostVotedScoreCache;
		let mostVotedScore = scores[0];
		for (const score of scores.slice(1)) {
			if (this.getScoreVotes(score) > this.getScoreVotes(mostVotedScore)) {
				mostVotedScore = score;
			}
		}
		return this.mostVotedScoreCache = mostVotedScore;
	}
	get votesOfMostVotedScore() {
		return this.getScoreVotes(this.mostVotedScore);
	}
}

//#endregion
//#region src/components/MyRating.ts
function renderMyRating(el, props) {
	const hoveredScore = new Watched(null);
	el = $(`
    <div style="float: right; display: flex; flex-direction: column;">
      <p style="font-size: 12px;">我的评价:
        <span class="alarm"></span>
      </p>
      <div class="stars-container"></div>
      <div class="message"></div>
    </div>
  `).replaceAll(el);
	const starsContainerEl = el.find(".stars-container");
	const { updateStarsContainer } = renderStars(starsContainerEl, {
		hoveredScore,
		onRateEpisode: rateEpisode,
		onUpdateScoreToAlarm: (score) => {
			if (score !== null) {
				$(el).find(".alarm").text(describeScoreEx(score));
			} else {
				$(el).find(".alarm").text("");
			}
		}
	});
	$(el).find(".rating-cancel").on("mouseover", () => hoveredScore.setValue("cancel")).on("mouseout", () => hoveredScore.setValue(null)).on("click", () => rateEpisode(null));
	props.ratedScore.watchDeferred((ratedScore) => updateStarsContainer(["normal", {
		ratedScore,
		hoveredScore: hoveredScore.getValueOnce()
	}]));
	hoveredScore.watch((hoveredScore$1) => {
		updateStarsContainer(["normal", {
			ratedScore: props.ratedScore.getValueOnce(),
			hoveredScore: hoveredScore$1
		}]);
	});
	const messageEl = el.find(".message");
	function updateMessage(value) {
		messageEl.attr("style", "");
		switch (value[0]) {
			case "none": {
				messageEl.text("");
				messageEl.css("display", "none");
				break;
			}
			case "processing": {
				messageEl.text("处理中…");
				messageEl.css("color", "grey");
				break;
			}
			case "loading": {
				messageEl.text("加载中…");
				messageEl.css("color", "grey");
				break;
			}
			case "error": {
				messageEl.text(value[1]);
				messageEl.css("color", "red");
				break;
			}
			case "auth_link": {
				messageEl.html(`
          若要查看或提交自己的单集评分,
          <br >
          请先<a class="l" target="_blank">授权此应用</a>。
          <br >
          单集评分应用需要以此来确认登录者。
        `);
				$(messageEl).find("a").attr("href", global_default.client.URL_AUTH_BANGUMI_PAGE);
				break;
			}
			case "requiring_fetch": {
				if (props.canRefetchAfterAuth) {
					messageEl.html(`
            点击<button class="l">此处</button>或刷新本页以获取。 
          `);
					$(messageEl).find("button").on("click", async () => {
						updateMessage(["loading"]);
						const resp = await global_default.client.getMyEpisodeRating();
						if (resp[0] === "auth_required") {
							global_default.token.setValue(null);
						} else if (resp[0] === "error") {
							const [_, _name, message] = resp;
							updateMessage(["error", message]);
						} else if (resp[0] === "ok") {
							const [_, data] = resp;
							updateMessage(["none"]);
							updateVotesData(props.votesData, {
								oldScore: props.ratedScore.getValueOnce(),
								newScore: data.score
							});
							props.ratedScore.setValue(data.score);
							global_default.updateCurrentEpisodeVisibilityFromServerRaw(data.visibility);
						} else {
							resp;
						}
					});
				} else {
					messageEl.text("请刷新本页以获取。");
				}
				break;
			}
			default: value;
		}
	}
	updateMessage(["none"]);
	global_default.token.watch((newToken, oldToken) => {
		if (newToken) {
			if (oldToken !== undefined) {
				if (props.isPrimary) {
					updateMessage(["requiring_fetch"]);
				}
				updateStarsContainer(["invisible"]);
			} else {
				updateMessage(["none"]);
			}
		} else {
			if (props.isPrimary) {
				updateMessage(["auth_link"]);
			} else {
				el.css("display", "none");
			}
			updateStarsContainer(["invisible"]);
		}
	});
	async function rateEpisode(scoreToRate) {
		if (!global_default.token.getValueOnce()) return;
		updateMessage(["processing"]);
		const resp = await global_default.client.rateEpisode({
			subjectID: global_default.subjectID,
			episodeID: props.episodeID,
			score: scoreToRate
		});
		if (resp[0] === "auth_required") {
			updateMessage(["auth_link"]);
		} else if (resp[0] === "error") {
			const [_, _name, message] = resp;
			updateMessage(["error", message]);
		} else if (resp[0] === "ok") {
			const [_, data] = resp;
			updateMessage(["none"]);
			updateVotesData(props.votesData, {
				oldScore: props.ratedScore.getValueOnce(),
				newScore: data.score
			});
			props.ratedScore.setValue(data.score);
			global_default.updateCurrentEpisodeVisibilityFromServerRaw(data.visibility);
		} else {
			resp;
		}
	}
}
function updateVotesData(votesData, opts) {
	const newVotesData = votesData.getValueOnce().getClonedData();
	if (opts.oldScore !== null) {
		newVotesData[opts.oldScore]--;
	}
	if (opts.newScore !== null) {
		newVotesData[opts.newScore] ??= 0;
		newVotesData[opts.newScore]++;
	}
	votesData.setValue(new VotesData(newVotesData));
}

//#endregion
//#region src/components/Scoreboard.ts
function renderScoreboard(el, props) {
	el = $(`
    <div class="global_score" style="float: right;">
      <span class="description"></span>
      <span class="number"></span>
      <div>
        <small class="grey" style="float: right;">单集评分</small>
      </div>
    </div>
  `).replaceAll(el);
	function updateNumber(score) {
		if (Number.isNaN(score)) {
			$(el).find(".number").text(0 .toFixed(1));
			$(el).find(".description").text("--");
		} else {
			$(el).find(".number").text(score.toFixed(4));
			$(el).find(".description").text(describeScore(score));
		}
	}
	props.votesData.watch((votesData) => {
		updateNumber(votesData.averageScore);
	});
}

//#endregion
//#region src/components/Tooltip.ts
function renderTooltip(el, props) {
	el = $(`
    <div class="tooltip fade top in" role="tooltip">
      <div class="tooltip-arrow" style="left: 50%;"></div>
      <div class="tooltip-inner"></div>
    </div>
  `).replaceAll(el);
	el.attr("style", props.initialStyle);
	const updateVisibility = (isVisible) => {
		el.css("display", isVisible ? "block" : "none");
	};
	const updateLeft = (leftPx) => {
		el.css("left", `${leftPx}px`);
	};
	const updateTop = (topPx) => {
		el.css("top", `${topPx}px`);
	};
	const updateContent = (text) => {
		el.find(".tooltip-inner").text(text);
		[];
	};
	return {
		updateVisibility,
		updateLeft,
		updateTop,
		updateContent
	};
}

//#endregion
//#region src/components/ScoreChart.ts
function renderScoreChart(el, props) {
	el = $(`
    <div id="ChartWarpper" class="chartWrapper" style="float: right; width: 218px;">
      <div class="chart_desc"><small class="grey"><span class="votes"></span> votes</small></div>
      <ul class="horizontalChart">
        <div data-sel="tooltip"></div>
      </ul>
    </div>
  `).replaceAll(el);
	const tooltip = renderTooltip(el.find("[data-sel='tooltip']"), { initialStyle: "top: -34px; transform: translateX(-50%);" });
	const chartEl = el.find(".horizontalChart");
	const barEls = scores.map(() => $("<div />").appendTo(chartEl));
	props.votesData.watch((votesData) => {
		$(el).find(".votes").text(votesData.totalVotes);
		const totalVotes = votesData.totalVotes;
		const votesOfMostVotedScore = votesData.votesOfMostVotedScore;
		for (const score of scores) {
			const votes = votesData.getScoreVotes(score);
			const barIndex = 10 - score;
			const { el: newBarEl } = renderBar(barEls[barIndex], {
				score,
				votes,
				totalVotes,
				votesOfMostVotedScore,
				updateTooltip
			});
			barEls[barIndex] = newBarEl;
		}
	});
	function updateTooltip(opts) {
		if (opts.score === null) {
			tooltip.updateVisibility(false);
			return;
		}
		tooltip.updateVisibility(true);
		const barEl = $(chartEl).find(`li`).eq(10 - opts.score);
		const barElRelativeOffsetLeft = barEl.offset().left - el.offset().left;
		tooltip.updateLeft(barElRelativeOffsetLeft + barEl.width() / 2);
		const votesData = props.votesData.getValueOnce();
		let scoreVotes = votesData.getScoreVotes(opts.score);
		const percentage = votesData.totalVotes ? scoreVotes / votesData.totalVotes * 100 : 0;
		tooltip.updateContent(`${percentage.toFixed(2)}% (${scoreVotes}人)`);
	}
	updateTooltip({ score: null });
	return el;
}
function renderBar(el, props) {
	el = $(`
    <li><a class="textTip"><span class="label"></span><span class="count"></span></a></li>
  `).replaceAll(el);
	const percentage = (props.votes / props.totalVotes * 100).toFixed(2);
	$(el).find(".textTip").attr("data-original-title", `${percentage}% (${props.votes}人)`);
	$(el).find(".label").text(props.score);
	const height = (props.votes / props.votesOfMostVotedScore * 100).toFixed(2);
	$(el).find(".count").css("height", `${height}%`);
	$(el).find(".count").text(`(${props.votes})`);
	$(el).on("mouseover", () => props.updateTooltip({ score: props.score })).on("mouseout", () => props.updateTooltip({ score: null }));
	return { el };
}

//#endregion
//#region src/components/SmallStars.ts
function renderSmallStars(el, props) {
	el = $(`
    <span>
      <span class="starstop-s">
        <span data-sel="starlight" class="starlight"></span>
      </span>
      <small class="fade"></small>
    </span>

  `).replaceAll(el);
	const starlightEl = $(el).find("[data-sel=\"starlight\"]");
	if (!props.shouldShowNumber) {
		$(el).find("small.fade").remove();
	}
	props.score.watch((score) => {
		if (Number.isNaN(score)) {
			$(starlightEl).removeClass();
			if (props.shouldShowNumber) {
				$(el).find("small.fade").text("--");
			}
		} else {
			$(starlightEl).removeClass().addClass("starlight").addClass(`stars${Math.round(score)}`);
			if (props.shouldShowNumber) {
				$(el).find("small.fade").text(score.toFixed(4));
			}
		}
	});
}

//#endregion
//#region src/components/VisibilityButton.ts
function renderVisibilityButton(el, opts) {
	el = $(`
    <span>
      <button></button>
      <span data-sel="message"></span>
    </span>
  `).replaceAll(el);
	const isDisabled = new Watched(false);
	const buttonEl = $(el).find("button");
	const messageEl = $(el).find("[data-sel='message']");
	opts.currentVisibility.watch((currentVisibility) => {
		if (currentVisibility === null) {
			$(el).css("display", "none");
			return;
		}
		$(el).css("display", "");
		if (currentVisibility.isVisible) {
			$(buttonEl).text("不再公开");
		} else {
			$(buttonEl).text("公开");
		}
	});
	isDisabled.watch((isDisabled$1) => {
		if (isDisabled$1) {
			$(buttonEl).attr("disabled", "disabled");
		} else {
			$(buttonEl).removeAttr("disabled");
		}
	});
	$(buttonEl).on("click", async () => {
		const currentVisibility = opts.currentVisibility.getValueOnce().isVisible;
		isDisabled.setValue(true);
		updateMessage(["processing"]);
		const result = await global_default.client.changeUserEpisodeRatingVisibility({ isVisible: !currentVisibility });
		if (result[0] === "auth_required") {
			global_default.token.setValue(null);
			updateMessage(["auth_link"]);
		} else if (result[0] === "error") {
			updateMessage(["error", result[1]]);
		} else if (result[0] === "ok") {
			isDisabled.setValue(false);
			updateMessage(["none"]);
			global_default.updateCurrentEpisodeVisibilityFromServerRaw(result[1]);
		} else {
			result;
		}
	});
	function updateMessage(value) {
		messageEl.attr("style", "");
		switch (value[0]) {
			case "none": {
				messageEl.text("");
				messageEl.css("display", "none");
				break;
			}
			case "processing": {
				messageEl.text("处理中…");
				messageEl.css("color", "grey");
				break;
			}
			case "error": {
				messageEl.text(value[1]);
				messageEl.css("color", "red");
				break;
			}
			case "auth_link": {
				messageEl.html(`
          请先<a class="l" target="_blank">授权此应用</a>。
        `);
				$(messageEl).find("a").attr("href", global_default.client.URL_AUTH_BANGUMI_PAGE);
				break;
			}
			case "requiring_reload": {
				messageEl.text("请刷新本页以操作。");
				break;
			}
			default: value;
		}
	}
	updateMessage(["none"]);
	global_default.token.watch((newToken, oldToken) => {
		if (newToken) {
			if (oldToken !== undefined) {
				isDisabled.setValue(true);
				updateMessage(["requiring_reload"]);
			} else {
				updateMessage(["none"]);
			}
		} else {
			isDisabled.setValue(true);
			updateMessage(["auth_link"]);
		}
	});
}

//#endregion
//#region src/components/ReplyFormVisibilityControl.ts
function renderReplyFormVisibilityControl(el, opts) {
	el = $(`
    <div style="height: 30.5px; float: right; display: flex; align-items: center;">
      <label>
        <input type="checkbox" />不要在我的吐槽旁公开我对本集的评分
      </label>
      <p>
        我的吐槽旁<span data-sel="negative-word">不</span>会公开我对本集的评分
        <div data-sel="button"></div>
      </p>
    </div>
  `).replaceAll(el);
	const checkBoxEl = $(el).find("input[type=\"checkbox\"]");
	const unwatchFn1 = opts.visibilityCheckboxValue.watch((value) => {
		$(checkBoxEl).prop("checked", value);
	});
	$(checkBoxEl).on("change", () => {
		opts.visibilityCheckboxValue.setValue($(checkBoxEl).is(":checked"));
	});
	const unwatchFn2 = opts.isVisibilityCheckboxRelevant.watch((isRelevant) => {
		$(el).find("label").css("display", isRelevant ? "flex" : "none");
	});
	const unwatchFn3 = opts.currentVisibility.watch((currentVisibility) => {
		if (currentVisibility === null) {
			$(el).find("p").css("display", "none");
		} else {
			$(el).find("p").css("display", "");
			if (currentVisibility.isVisible) {
				$(el).find("[data-sel=\"negative-word\"]").css("display", "none");
			} else {
				$(el).find("[data-sel=\"negative-word\"]").css("display", "");
			}
		}
	});
	const buttonEl = $(el).find("[data-sel=\"button\"]");
	renderVisibilityButton(buttonEl, opts);
	function unmount() {
		[unwatchFn1, unwatchFn2, unwatchFn3].forEach((fn) => fn());
	}
	return { unmount };
}

//#endregion
//#region src/components/MyRatingInComment.ts
function renderMyRatingInComment(el, opts) {
	el = $(` 
    <span>
      <div data-sel="small-stars"></div>
      <span data-sel="visibility-control">
        <span data-sel="description" style="font-size: 12px;"></span>
        <div data-sel="visibility-button"></div>
      </span>
    </span>
  `).replaceAll(el);
	const smallStarsEl = el.find("[data-sel=\"small-stars\"]");
	const visibilityControlEl = el.find("[data-sel=\"visibility-control\"]");
	const visibilityDescriptionEl = $(visibilityControlEl).find("[data-sel=\"description\"]");
	const visibilityButtonEl = $(visibilityControlEl).find("[data-sel=\"visibility-button\"]");
	renderSmallStars(smallStarsEl, {
		score: opts.ratedScore,
		shouldShowNumber: false
	});
	opts.currentVisibility.watch((currentVisibility) => {
		if (currentVisibility === null) {
			visibilityControlEl.css("display", "none");
			return;
		}
		visibilityControlEl.css("display", "");
		if (currentVisibility.isVisible) {
			visibilityDescriptionEl.text("已公开评分");
			visibilityButtonEl.text("不再公开");
		} else {
			visibilityDescriptionEl.text("未公开评分");
			visibilityButtonEl.text("公开");
		}
	});
	renderVisibilityButton(visibilityButtonEl, opts);
}

//#endregion
//#region src/components/ErrorWithRetry.ts
function renderErrorWithRetry(el, props) {
	$(el).css("color", "red");
	$(el).html(`
    <span></span>
    <button type="button">重试</button>
  `);
	$(el).find("span").text(`错误:${props.message}`);
	$(el).find("button").on("click", props.onRetry);
	return { el };
}

//#endregion
//#region src/page-processors/ep.ts
async function processEpPage() {
	const el = $(`
    <div style="color: grey; float: right;">
      单集评分加载中…
    </div>
  `);
	$("#columnEpA").prepend(el);
	processEpPageInternal({ el });
}
async function processEpPageInternal(opts) {
	const resp = await global_default.client.getEpisodeRatings();
	if (resp[0] === "auth_required") throw new Error("unreachable");
	if (resp[0] === "error") {
		const [_$1, _name, message] = resp;
		const { el } = renderErrorWithRetry(opts.el, {
			message,
			onRetry: () => processEpPageInternal(opts)
		});
		opts.el = el;
		return;
	}
	resp[0];
	const [_, ratingsData] = resp;
	const votesData = new Watched(new VotesData(ratingsData.votes));
	global_default.updateCurrentEpisodeVisibilityFromServerRaw(ratingsData.my_rating?.visibility);
	renderScoreboard(opts.el, { votesData });
	const scoreChartEl = $("<div />").insertBefore("#columnEpA > .epDesc");
	renderScoreChart(scoreChartEl, { votesData });
	$(`<div class="clear" />`).insertAfter("#columnEpA > .epDesc");
	const myRatingEl = $("<div />").insertAfter(".singleCommentList > .board");
	if (!ratingsData.my_rating) {
		global_default.token.setValue(null);
	}
	const ratedScore = new Watched(ratingsData.my_rating?.score ?? null);
	renderMyRating(myRatingEl, {
		episodeID: global_default.episodeID,
		ratedScore,
		isPrimary: true,
		canRefetchAfterAuth: true,
		votesData
	});
	const userReplyMap = await collectUserReplyMap();
	const myReplies = new Watched(collectMyReplies());
	{
		const oldInsertFn = chiiLib.ajax_reply.insertJsonComments;
		chiiLib.ajax_reply.insertJsonComments = function(...args) {
			oldInsertFn.apply(this, args);
			myReplies.setValue(collectMyReplies());
		};
	}
	const currentVisibility = (() => {
		const watched = new Watched(null);
		function update() {
			if (!myReplies.getValueOnce().length) {
				watched.setValue(null);
			} else {
				watched.setValue(global_default.currentEpisodeVisibilityFromServer.getValueOnce());
			}
		}
		myReplies.watchDeferred(update);
		global_default.currentEpisodeVisibilityFromServer.watch(update);
		return watched;
	})();
	const ratedScoreGeneric = ratedScore.createComputed((score) => score ?? NaN);
	myReplies.watch((myReplies$1) => {
		processMyUnprocessedComments({
			ratedScore: ratedScoreGeneric,
			currentVisibility,
			replies: myReplies$1
		});
	});
	const votersToScore = convertVotersByScoreToVotersToScore(ratingsData.public_ratings.public_voters_by_score);
	processOtherPeoplesComments({
		votersToScore,
		userReplyMap,
		myUserID: global_default.claimedUserID
	});
	const isVisibilityCheckboxRelevant = (() => {
		const watched = new Watched(true);
		function update() {
			watched.setValue(ratedScore.getValueOnce() !== null && currentVisibility.getValueOnce() === null);
		}
		ratedScore.watchDeferred(update);
		currentVisibility.watch(update);
		return watched;
	})();
	const visibilityCheckboxValue = new Watched(false, { shouldDeduplicateShallowly: true });
	processReplyForm({
		isVisibilityCheckboxRelevant,
		visibilityCheckboxValue,
		currentVisibility
	});
	processReplysForm({
		isVisibilityCheckboxRelevant,
		visibilityCheckboxValue,
		currentVisibility
	});
}
function processReplyForm(opts) {
	const el = $("#ReplyForm");
	const submitButtonEl = $(el).find("#submitBtnO");
	const controlEl = $("<div />").insertBefore(submitButtonEl);
	renderReplyFormVisibilityControl(controlEl, opts);
	$(el.on("submit", async () => {
		changeVisibilityIfNecessary({
			isRelevant: opts.isVisibilityCheckboxRelevant.getValueOnce(),
			currentVisibility: opts.currentVisibility.getValueOnce(),
			changedVisibility: { isVisible: !opts.visibilityCheckboxValue.getValueOnce() }
		});
	}));
}
function processReplysForm(opts) {
	const unmountFns = [];
	const oldSubReplyFn = (window.unsafeWindow ?? window).subReply;
	(window.unsafeWindow ?? window).subReply = function(...args) {
		oldSubReplyFn(...args);
		const el = $("#ReplysForm");
		const submitButtonEl = $(el).find("#submitBtnO");
		const controlEl = $("<div />").insertBefore(submitButtonEl);
		const { unmount: unmountFn } = renderReplyFormVisibilityControl(controlEl, opts);
		unmountFns.push(unmountFn);
		$(el.on("submit", async () => {
			unmountFns.forEach((fn) => fn());
			await changeVisibilityIfNecessary({
				isRelevant: opts.isVisibilityCheckboxRelevant.getValueOnce(),
				currentVisibility: opts.currentVisibility.getValueOnce(),
				changedVisibility: { isVisible: !opts.visibilityCheckboxValue.getValueOnce() }
			});
		}));
	};
	const oldSubReplycancelFn = (window.unsafeWindow ?? window).subReplycancel;
	(window.unsafeWindow ?? window).subReplycancel = function(...args) {
		unmountFns.forEach((fn) => fn());
		oldSubReplycancelFn(...args);
	};
}
async function changeVisibilityIfNecessary(opts) {
	if (!opts.isRelevant) return;
	if (opts.currentVisibility?.isVisible === opts.changedVisibility.isVisible) {
		return;
	}
	const result = await global_default.client.changeUserEpisodeRatingVisibility({ isVisible: opts.changedVisibility.isVisible });
	if (result[0] === "auth_required") {
		global_default.token.setValue(null);
	} else if (result[0] === "error") {
		console.warn("单集评分组件", "`changeUserEpisodeRatingVisibility`", result);
	} else if (result[0] === "ok") {
		global_default.updateCurrentEpisodeVisibilityFromServerRaw(result[1]);
	} else {
		result;
	}
}
function processMyUnprocessedComments(opts) {
	for (const reply of opts.replies) {
		const el = $(reply.el);
		if (el.hasClass("__bgm_ep_ratings__processed")) continue;
		el.addClass("__bgm_ep_ratings__processed");
		const myRatingInCommentEl = $("<div />").insertBefore($(el).find(".inner > .reply_content,.cmt_sub_content").eq(0));
		renderMyRatingInComment(myRatingInCommentEl, opts);
	}
}
function processOtherPeoplesComments(opts) {
	for (const [voterUserID_, score] of Object.entries(opts.votersToScore)) {
		const voterUserID = Number(voterUserID_);
		if (voterUserID === opts.myUserID) continue;
		for (const reply of opts.userReplyMap[voterUserID] ?? []) {
			const el = $(reply.el);
			const smallStarsEl = $("<div />").insertBefore($(el).find(".inner > .reply_content,.cmt_sub_content").eq(0));
			renderSmallStars(smallStarsEl, {
				score: new Watched(score),
				shouldShowNumber: false
			});
		}
	}
}
async function collectUserReplyMap() {
	const replies = await collectReplies();
	const output = {};
	for (const reply of replies) {
		(output[reply.userID] ??= []).push(reply);
	}
	return output;
}
async function collectReplies() {
	let output = [];
	let timeStart = performance.now();
	for (const el of document.querySelectorAll("[id^=\"post_\"]")) {
		const isSubReply = isElementSubReply(el);
		const replyOnClickText = $(el).find("a:has(> span.ico_reply)").eq(0).attr("onclick");
		if (!replyOnClickText) {
			continue;
		}
		const args = /\((.*)\)/.exec(replyOnClickText)[1].split(",").map((arg) => arg.trim());
		const userID = Number(isSubReply ? args.at(-3) : args.at(-2));
		output.push({
			el,
			isSubReply,
			userID
		});
		if (performance.now() - timeStart >= 10) {
			await new Promise((resolve) => setTimeout(resolve, 0));
			timeStart = performance.now();
		}
	}
	return output;
}
function collectMyReplies() {
	if (!global_default.token.getValueOnce()) return [];
	const myTextUserID = new URL($("#headerNeue2 .idBadgerNeue > .avatar").attr("href")).pathname.split("/").filter(Boolean).at(-1);
	return $(`[id^="post_"]:has(> a.avatar[href$="/${myTextUserID}"])`).map((_, el) => ({
		el,
		isSubReply: isElementSubReply(el)
	})).toArray();
}
function isElementSubReply(el) {
	return !!$(el).closest(".topic_sub_reply").length;
}
function convertVotersByScoreToVotersToScore(votersByScore) {
	const output = {};
	for (const [score, voters] of Object.entries(votersByScore)) {
		for (const voter of voters) {
			output[voter] = Number(score);
		}
	}
	return output;
}

//#endregion
//#region src/components/RateInfo.ts
function renderRateInfo(el, props) {
	el = $(`
    <div>
      <div class="rateInfo" style="display: none;">
        <div data-sel="small-stars"></div>
        <span class="tip_j"></span>
      </div>
      <button type="button" style="display: none;">显示评分</button>
    </div>
  `).replaceAll(el);
	const rateInfoEl = el.find(".rateInfo");
	const smallStarsEl = el.find("[data-sel=\"small-stars\"]");
	const buttonEl = el.find("button");
	const score = props.votesData.createComputed((votesData) => votesData.averageScore);
	renderSmallStars(smallStarsEl, {
		score,
		shouldShowNumber: true
	});
	props.votesData.watch((votesData) => {
		$(el).find(".tip_j").text(`(${votesData.totalVotes}人评分)`);
	});
	buttonEl.on("click", () => {
		rateInfoEl.css("display", "");
		buttonEl.css("display", "none");
		props.onReveal?.();
	});
	props.requiresClickToReveal.watch((requiresClickToReveal) => {
		if (requiresClickToReveal) {
			rateInfoEl.css("display", "none");
			buttonEl.css("display", "");
		} else {
			rateInfoEl.css("display", "");
			buttonEl.css("display", "none");
		}
	});
}

//#endregion
//#region src/element-processors/cluetip.ts
function processCluetip() {
	let counter = 0;
	const revealed = {};
	async function update(opts) {
		const el = $("#cluetip");
		const popupEl = $(el).find(".prg_popup");
		if (popupEl.attr("data-bgm-ep-ratings-initialized")) return;
		popupEl.attr("data-bgm-ep-ratings-initialized", "true");
		counter++;
		const currentCounter = counter;
		if (!global_default.client.hasCachedSubjectEpisodesRatings(opts.subjectID)) {
			await new Promise((resolve) => setTimeout(resolve, 250));
			if (currentCounter !== counter || !popupEl.is(":visible")) return;
		}
		const loadingEl = $(`
      <div style="color: grey">
        单集评分加载中…
      </div>
    `).insertBefore($(popupEl).find(".tip .board:first"));
		updateInternal({
			...opts,
			currentCounter,
			loadingEl,
			popupEl
		});
	}
	async function updateInternal(opts) {
		const resp = await global_default.client.getSubjectEpisodesRatings({ subjectID: opts.subjectID });
		if (resp[0] === "error") {
			const [_$1, _name, message] = resp;
			const { el } = renderErrorWithRetry(opts.loadingEl, {
				message,
				onRetry: () => updateInternal(opts)
			});
			opts.loadingEl = el;
			return;
		}
		resp[0];
		const [_, epsRatings] = resp;
		opts.loadingEl.remove();
		if (opts.currentCounter !== counter) return;
		const votesData = new Watched(new VotesData(epsRatings.episodes_votes[opts.episodeID] ?? {}));
		const requiresClickToReveal = new Watched(false);
		requiresClickToReveal.setValue(!(opts.hasUserWatched || revealed[`${opts.subjectID}:${opts.episodeID}`] || !votesData.getValueOnce().totalVotes));
		function revealScore() {
			revealed[`${opts.subjectID}:${opts.episodeID}`] = true;
			requiresClickToReveal.setValue(false);
		}
		const rateInfoEl = $("<div />").insertBefore($(opts.popupEl).find(".tip .board:first"));
		renderRateInfo(rateInfoEl, {
			votesData,
			requiresClickToReveal,
			onReveal: () => {
				revealed[`${opts.subjectID}:${opts.episodeID}`] = true;
			}
		});
		$(opts.popupEl).find(".epStatusTool > a.ep_status").each((_$1, epStatusEl) => {
			if (epStatusEl.id.startsWith("Watched")) {
				$(epStatusEl).on("click", () => revealScore());
			}
		});
	}
	return { update };
}

//#endregion
//#region src/utils/date-formatting.ts
function formatDate(date, opts) {
	const nowDayNumber = calculateDayNumber(opts.now);
	const dayNumber = calculateDayNumber(date);
	if (dayNumber === nowDayNumber) return "今天";
	if (dayNumber === nowDayNumber - 1) return "昨天";
	const y = date.getFullYear(), m = date.getMonth() + 1, d = date.getDate();
	return `${y}-${m}-${d}`;
}
function formatDateToTime(date) {
	const y = date.getFullYear(), m = date.getMonth() + 1, d = date.getDate();
	const h = date.getHours(), min = date.getMinutes();
	const hStr = h < 10 ? `0${h}` : h;
	const minStr = min < 10 ? `0${min}` : min;
	return `${y}-${m}-${d} ${hStr}:${minStr}`;
}
function formatDatesDifferences(dateA, dateB) {
	const diff = dateB.getTime() - dateA.getTime();
	let suffix;
	if (diff < 0) {
		suffix = "后";
		[dateA, dateB] = [dateB, dateA];
	} else {
		suffix = "前";
	}
	const tsA = Math.floor(dateA.getTime() / 1000), tsB = Math.floor(dateB.getTime() / 1000);
	const secondsA = tsA % 60, secondsB = tsB % 60;
	let diffSeconds = secondsB - secondsA;
	const minutesA = Math.floor(tsA / 60) % 60, minutesB = Math.floor(tsB / 60) % 60;
	let diffMinutes = minutesB - minutesA;
	if (diffSeconds < 0) {
		diffMinutes--;
		diffSeconds += 60;
	}
	const hoursA = dateA.getHours(), hoursB = dateB.getHours();
	let diffHours = hoursB - hoursA;
	if (diffMinutes < 0) {
		diffHours--;
		diffMinutes += 60;
	}
	const daysA = dateA.getDate(), daysB = dateB.getDate();
	let diffDays = daysB - daysA;
	if (diffHours < 0) {
		diffDays--;
		diffHours += 24;
	}
	const monthsA = dateA.getMonth() + 1, monthsB = dateB.getMonth() + 1;
	let diffMonths = monthsB - monthsA;
	if (diffDays < 0) {
		diffMonths--;
		const daysInMonth = new Date(dateA.getFullYear(), dateA.getMonth() + 1, 0).getDate();
		diffDays += daysInMonth;
	}
	const yearsA = dateA.getFullYear(), yearsB = dateB.getFullYear();
	let diffYears = yearsB - yearsA;
	if (diffMonths < 0) {
		diffYears--;
		const monthsInYear = 12;
		diffMonths += monthsInYear;
	}
	let ret;
	if (diffYears !== 0) {
		ret = `${diffYears}年${diffMonths > 0 ? `${diffMonths}月` : ""}`;
	} else if (diffMonths !== 0) {
		ret = `${diffMonths}月${diffDays > 0 ? `${diffDays}天` : ""}`;
	} else if (diffDays !== 0) {
		ret = `${diffDays}天${diffHours > 0 ? `${diffHours}时` : ""}`;
	} else if (diffHours !== 0) {
		ret = `${diffHours}小时${diffMinutes > 0 ? `${diffMinutes}分钟` : ""}`;
	} else if (diffMinutes !== 0) {
		ret = `${diffMinutes}分钟${diffSeconds > 0 ? `${diffSeconds}秒` : ""}`;
	} else {
		ret = `${diffSeconds}秒`;
	}
	return `${ret}${suffix}`;
}
const timezoneOffsetSeconds = new Date().getTimezoneOffset() * 60 * 1000;
function calculateDayNumber(date) {
	const timestamp = Math.floor(date.getTime() / 1000);
	const localTimestamp = timestamp - timezoneOffsetSeconds;
	return Math.floor(localTimestamp / (24 * 60 * 60));
}

//#endregion
//#region src/utils/simple-intersection-observer.ts
const callbackMap = new WeakMap();
const observer = new IntersectionObserver((entries) => {
	entries.forEach((entry) => {
		const cb = callbackMap.get(entry.target);
		if (cb && entry.isIntersecting) {
			cb();
			observer.unobserve(entry.target);
		}
	});
});
function observeInteractionWithViewportOnce(el, cb) {
	callbackMap.set(el, cb);
	observer.observe(el);
}
setInterval(() => {
	observer.takeRecords().forEach((record) => {
		if (!document.contains(record.target)) {
			observer.unobserve(record.target);
		}
	});
}, 1000);

//#endregion
//#region src/components/TimelineContent.ts
function renderTimelineContent(el, props) {
	const now = new Date();
	const episodeToSubjectMap = makeEpisodeToSubjectMap(props.data.subjects);
	el = $(`
    <div id="timeline" style="position: relative;">
    </div>
  `).replaceAll(el);
	el.attr(props.dataAttributeName, "true");
	let lastDateStr = null;
	let ulEl;
	let tooltip;
	let lastUserTextID = null;
	for (const [timestampMs, type, payload] of props.data.items) {
		const date = new Date(timestampMs);
		const dateStr = formatDate(date, { now });
		if (lastDateStr !== dateStr) {
			lastDateStr = dateStr;
			el.append(`<h4 class="Header">${dateStr}</h4>`);
			ulEl = $("<ul>").appendTo(el);
			lastUserTextID = null;
		}
		let userTextID = null;
		let episodeID = null;
		let subjectID = null;
		let itemEl = null;
		if (type === "rate-episode") {
			userTextID = global_default.claimedUserTextID;
			const userName = global_default.claimedUserName;
			episodeID = payload.episode_id;
			itemEl = $(`
        <li class="clearit tml_item">
          <span class="info clearit">
            <span>
              <a href="/user/${userTextID}" class="l">${userName}</a>
              为剧集
              <a data-sel="ep-title-link" href="/ep/${episodeID}" class="l">${episodeID}</a>
              <span data-sel="action"></span>
            </span>
          </span>
          <a title="删除这条时间线" class="tml_del" style="display: none; cursor: pointer;">del</a>
        </li>
      `).appendTo(ulEl);
			const actionEl = itemEl.find("[data-sel='action']");
			if (payload.score !== null) {
				actionEl.html("评分 <span data-sel=\"stars\"></span>");
				renderSmallStars(actionEl.find("[data-sel='stars']"), {
					score: new Watched(payload.score),
					shouldShowNumber: false
				});
			} else {
				actionEl.html("取消评分");
			}
			const delEl = itemEl.find(".tml_del");
			itemEl.on("mouseenter", () => delEl.css("display", "block"));
			itemEl.on("mouseleave", () => delEl.css("display", "none"));
			delEl.on("click", async () => {
				const result = await global_default.client.deleteMyTimelineItem({ timestampMs });
				switch (result[0]) {
					case "ok":
						itemEl.remove();
						break;
					case "error":
						alert("删除单集评分时间线项目失败:" + result[2]);
						break;
					case "auth_required":
						alert("认证失败。");
						global_default.token.setValue(null);
						break;
				}
			});
			subjectID = episodeToSubjectMap[episodeID];
		}
		const epTitleLinkEl = itemEl?.find("[data-sel='ep-title-link']");
		epTitleLinkEl?.each((_, el$1) => {
			observeInteractionWithViewportOnce(el$1, async () => {
				$(el$1).text($(el$1).text() + "(加载中…)");
				const title = await global_default.bangumiClient.getEpisodeTitle(episodeID);
				$(el$1).text(title);
			});
		});
		const infoEl = itemEl?.find(".info");
		if (infoEl?.length) {
			if (subjectID) {
				const cardEl = $(`
          <div class="card card_tiny">
            <div class="container">
              <a href="/subject/${subjectID}">
                <span class="cover">
                  <img loading="lazy">
                </span>
              </a>
            </div>
          </div>
        `).appendTo(infoEl);
				const url = `https://api.bgm.tv/v0/subjects/${subjectID}/image?type=grid`;
				cardEl.find("img").attr("src", url);
			}
			{
				const extraEl = $(`
          <div class="post_actions date">
            <span class="titleTip"></span>
            · <small class="grey"><a target="_blank">单集评分</a></small>
          </div>
        `).appendTo(infoEl);
				{
					extraEl.find("a").attr("href", "/dev/app/3263");
				}
				const titleTipEl = extraEl.find(".titleTip");
				titleTipEl.text(formatDatesDifferences(date, now));
				titleTipEl.on("mouseover", () => {
					tooltip.updateVisibility(true);
					const relativeLeft = titleTipEl.offset().left - el.offset().left;
					const relativeTop = titleTipEl.offset().top - el.offset().top;
					tooltip.updateLeft(relativeLeft + titleTipEl.width() / 2);
					tooltip.updateTop(relativeTop);
					tooltip.updateContent(formatDateToTime(date));
				}).on("mouseout", () => tooltip.updateVisibility(false));
			}
		}
		if (itemEl && lastUserTextID !== userTextID) {
			const avatarEl = $(`
        <span class="avatar">
          <a href="/user/${userTextID}" class="avatar">
            <span class="avatarNeue avatarReSize40 ll"></span>
          </a>
        </span>
      `).prependTo(ulEl.find("> li:last"));
			if (userTextID) {
				const safeUserTextID = encodeURIComponent(userTextID);
				const url = `https://api.bgm.tv/v0/users/${safeUserTextID}/avatar?type=small`;
				avatarEl.find(".avatarNeue").css("background-image", `url('${url}')`);
			}
			lastUserTextID = userTextID;
		}
	}
	{
		const pagerEl = $(`
      <div id="tmlPager">
        <div class="page_inner"></div>
      </div>
    `).appendTo(el);
		const innerEl = pagerEl.find(".page_inner");
		if (props.onClickPreviousPageButton) {
			const prevEl = $(`<a class="p">‹‹上一页</a>`).appendTo(innerEl);
			prevEl.on("click", (ev) => {
				ev.preventDefault();
				props.onClickPreviousPageButton();
			});
		}
		if (props.onClickNextPageButton) {
			const nextEl = $(`<a class="p">下一页››</a>`).appendTo(innerEl);
			nextEl.on("click", (ev) => {
				ev.preventDefault();
				props.onClickNextPageButton();
			});
		} else {
			$(`<span>没有下一页了…</span>`).appendTo(innerEl);
		}
	}
	tooltip = renderTooltip($("<div />").appendTo(el), { initialStyle: "transform: translate(-50%, -100%);" });
}
function makeEpisodeToSubjectMap(subjectsData) {
	const map = {};
	for (const [subjectID_, subjectData] of Object.entries(subjectsData)) {
		const subjectID = Number(subjectID_);
		for (const episodeID of subjectData.episode_ids) {
			map[episodeID] = subjectID;
		}
	}
	return map;
}

//#endregion
//#region src/page-processors/root.ts
const TIMELINE_CONTENT_DATA_ATTRIBUTE_NAME = "data-bgm-ep-ratings-timeline-content";
const TIMELINE_TOP_BAR_ID = "__bgm_ep_ratings__tl_top_bar";
async function processRootPage() {
	$(".load-epinfo").each((_, el) => {
		const href = $(el).attr("href");
		const title = $(el).attr("title");
		if (!href || !title) return;
		const episodeID = Number(href.split("/").at(-1));
		const m = /^ep\.(.+?) (.+)$/.exec(title);
		if (isNaN(episodeID) || !m) return;
		const sort = Number(m[1]);
		const name = m[2];
		if (isNaN(sort)) return;
		global_default.bangumiClient.putEntryIntoEpisodeCache(episodeID, {
			name,
			sort
		});
	});
	const { update: updateCluetip } = processCluetip();
	let isMouseOver = false;
	$("ul.prg_list > li").each((_, liEl) => {
		if (!$(liEl).find(".load-epinfo").length) return;
		$(liEl).on("mouseover", () => {
			if (isMouseOver) return;
			isMouseOver = true;
			const aEl = $(liEl).find("a");
			const subjectID = Number($(aEl).attr("subject_id"));
			const episodeID = (() => {
				const href = $(aEl).attr("href");
				const match = href.match(/^\/ep\/(\d+)/);
				return Number(match[1]);
			})();
			updateCluetip({
				subjectID,
				episodeID,
				hasUserWatched: aEl.hasClass("epBtnWatched")
			});
		}).on("mouseout", () => {
			isMouseOver = false;
		});
	});
	const tlButtonID = "__bgm_ep_ratings__tl_button";
	global_default.token.watch((token) => {
		if (!token) {
			$(`#${tlButtonID}`).remove();
			if ($(`#tmlContent [${TIMELINE_CONTENT_DATA_ATTRIBUTE_NAME}]`).length) {
				backToMainTimelineTab();
			}
			return;
		}
		$(`
      <li id="${tlButtonID}">
        <a style="cursor: pointer;">
          <span>我的单集评分</span>
        </a>
      </li>`).appendTo("ul#timelineTabs > li:has(a.top) > ul").on("click", async () => {
			$("#timelineTabs > li > a.focus").removeClass("focus");
			const containerEl = $("#tmlContent");
			if (!containerEl.find(`#${TIMELINE_TOP_BAR_ID}`).length) {
				$(`
            <div id="${TIMELINE_TOP_BAR_ID}">
              <button>导出我的单集评分数据</button>
            </div>
          `).prependTo(containerEl).on("click", () => {
					global_default.client.downloadMyEpisodeRatingsData();
				});
			}
			await processMyTimelineContent(containerEl, { pageNumber: 1 });
		});
	});
}
async function processMyTimelineContent(containerEl, opts) {
	renderLoading(clearContainerAndGetNewChildElement(containerEl), { attributeName: TIMELINE_CONTENT_DATA_ATTRIBUTE_NAME });
	const resp = await global_default.client.getMyTimelineItems(opts);
	if (resp[0] === "auth_required") {
		global_default.token.setValue(null);
		backToMainTimelineTab();
	} else if (resp[0] === "error") {
		const [_, _name, message] = resp;
		renderErrorWithRetry(clearContainerAndGetNewChildElement(containerEl), {
			message,
			onRetry: () => processMyTimelineContent(containerEl, opts)
		});
	} else {
		resp[0];
		const [_, data] = resp;
		const onClickPreviousPageButton = opts.pageNumber > 1 ? () => processMyTimelineContent(containerEl, { pageNumber: opts.pageNumber - 1 }) : null;
		const isPageFull = data.items.length === global_default.client.TIMELINE_ITEMS_PER_PAGE;
		const onClickNextPageButton = opts.pageNumber < 10 && isPageFull ? () => processMyTimelineContent(containerEl, { pageNumber: opts.pageNumber + 1 }) : null;
		renderTimelineContent(clearContainerAndGetNewChildElement(containerEl), {
			data,
			dataAttributeName: TIMELINE_CONTENT_DATA_ATTRIBUTE_NAME,
			onClickPreviousPageButton,
			onClickNextPageButton
		});
	}
}
function renderLoading(el, opts) {
	$(`
  <div class="loading">
    <img src="/img/loadingAnimation.gif">
  </div>`).replaceAll(el).attr(opts.attributeName, "true");
}
function backToMainTimelineTab() {
	$("#tab_all").trigger("click");
}
function clearContainerAndGetNewChildElement(containerEl) {
	containerEl.children().filter((_, el) => el.id !== TIMELINE_TOP_BAR_ID).remove();
	return $("<div />").appendTo(containerEl);
}

//#endregion
//#region src/page-processors/subject.ts
async function processSubjectPage() {
	const { update: updateCluetip } = processCluetip();
	let isMouseOver = false;
	$("ul.prg_list > li").each((_, liEl) => {
		if (!$(liEl).find(".load-epinfo").length) return;
		$(liEl).on("mouseover", () => {
			if (isMouseOver) return;
			isMouseOver = true;
			const aEl = $(liEl).find("a");
			const episodeID = (() => {
				const href = $(aEl).attr("href");
				const match = href.match(/^\/ep\/(\d+)/);
				return Number(match[1]);
			})();
			updateCluetip({
				subjectID: global_default.subjectID,
				episodeID,
				hasUserWatched: aEl.hasClass("epBtnWatched")
			});
		}).on("mouseout", () => {
			isMouseOver = false;
		});
	});
}

//#endregion
//#region src/page-processors/subject-ep-list.ts
async function processSubjectEpListPage() {
	const editEpBatchEl = $("[name=\"edit_ep_batch\"]");
	let loadingEl = null;
	$(editEpBatchEl).find("li").each((_, li) => {
		if (!$(li).find("[name=\"ep_mod[]\"]").length) return;
		$(`<div class="clear"></div>`).insertAfter($(li).find("h6"));
		loadingEl = $(`
      <div style="color: grey; float: right;">
        单集评分加载中…
      </div>
    `).appendTo(li);
		return false;
	});
	if (loadingEl) {
		processSubjectEpListPageInternal({
			loadingEl,
			editEpBatchEl
		});
	}
}
async function processSubjectEpListPageInternal(opts) {
	const resp = await global_default.client.getSubjectEpisodesRatings({ subjectID: global_default.subjectID });
	if (resp[0] === "error") {
		const [_$1, _name, message] = resp;
		const { el } = renderErrorWithRetry(opts.loadingEl, {
			message,
			onRetry: () => processSubjectEpListPageInternal(opts)
		});
		opts.loadingEl = el;
		return;
	}
	resp[0];
	const [_, epsRatings] = resp;
	if (opts.loadingEl) {
		opts.loadingEl.remove();
	}
	if (!epsRatings.my_ratings) {
		global_default.token.setValue(null);
	}
	let isFirst_ = true;
	$(opts.editEpBatchEl).find("li").each((_$1, li) => {
		if (!$(li).find("[name=\"ep_mod[]\"]").length) return;
		const isFirst = isFirst_;
		isFirst_ = false;
		if (!isFirst) {
			$(`<div class="clear"></div>`).insertAfter($(li).find("h6"));
		}
		const episodeID = (() => {
			const href = $(li).find("> h6 > a").attr("href");
			const match = /\/ep\/(\d+)/.exec(href);
			return Number(match[1]);
		})();
		const ratings = epsRatings.episodes_votes[episodeID];
		const votesData = new Watched(new VotesData(ratings ?? {}));
		const myRating = epsRatings.my_ratings?.[episodeID];
		const hasUserWatched = $(li).find(".statusWatched").length || myRating !== undefined;
		const myRatingEl = $("<div />");
		$(li).append(myRatingEl);
		renderMyRating(myRatingEl, {
			episodeID,
			ratedScore: new Watched(myRating ?? null),
			isPrimary: isFirst,
			canRefetchAfterAuth: false,
			votesData
		});
		const rateInfoEl = $("<div />");
		$(li).append(rateInfoEl);
		renderRateInfo(rateInfoEl, {
			votesData,
			requiresClickToReveal: new Watched(!hasUserWatched && !!votesData.getValueOnce().totalVotes)
		});
		$(li).append($(`<div class="clear"></div>`));
	});
}

//#endregion
//#region src/main.ts
function migrate() {
	const tokenInWrongPlace = localStorage.getItem("bgm_test_app_token");
	if (tokenInWrongPlace) {
		localStorage.setItem(env_default.LOCAL_STORAGE_KEY_TOKEN, tokenInWrongPlace);
		localStorage.removeItem("bgm_test_app_token");
	}
	const searchParams = new URLSearchParams(window.location.search);
	const tokenCouponInWrongPlace = searchParams.get("bgm_test_app_token_coupon");
	if (tokenCouponInWrongPlace) {
		searchParams.set(env_default.SEARCH_PARAMS_KEY_TOKEN_COUPON, tokenCouponInWrongPlace);
		searchParams.delete("bgm_test_app_token_coupon");
		let newURL = `${window.location.pathname}?${searchParams.toString()}`;
		window.history.replaceState(null, "", newURL);
	}
}
async function main() {
	const isInUserScriptRuntime = typeof GM_info !== "undefined";
	if ($("meta[name=\"__bgm_ep_ratings__initialized\"]").length) {
		console.warn("检测到本脚本/超合金组件(单集评分 by Umajho A.K.A. um)先前已经初始化过,本实例将不会继续运行。", {
			version: global_default.version,
			isInUserScriptRuntime
		});
		return;
	}
	$("<meta name=\"__bgm_ep_ratings__initialized\" content=\"true\">").appendTo("head");
	const searchParams = new URLSearchParams(window.location.search);
	const tokenCoupon = searchParams.get(env_default.SEARCH_PARAMS_KEY_TOKEN_COUPON);
	if (tokenCoupon) {
		searchParams.delete(env_default.SEARCH_PARAMS_KEY_TOKEN_COUPON);
		let newURL = `${window.location.pathname}`;
		if (searchParams.size) {
			newURL += `?${searchParams.toString()}`;
		}
		window.history.replaceState(null, "", newURL);
		const resp = await global_default.client.redeemTokenCoupon(tokenCoupon);
		if (resp[0] === "ok") {
			global_default.token.setValue(resp[1]);
		} else if (resp[0] === "error") {
			window.alert(`获取 token 失败:${resp[2]} (${resp[1]})`);
		} else {
			resp;
		}
		window.close();
	}
	const pathParts = window.location.pathname.split("/").filter(Boolean);
	if (!pathParts.length) {
		await processRootPage();
	} else if (pathParts.length === 2 && pathParts[0] === "subject") {
		await processSubjectPage();
	} else if (pathParts.length === 3 && pathParts[0] === "subject" && pathParts[2] === "ep") {
		await processSubjectEpListPage();
	} else if (pathParts.length === 2 && pathParts[0] === "ep") {
		await processEpPage();
	}
}
migrate();
initializeGlobal();
main();

//#endregion
})();