Skip to content

Commit b677aaf

Browse files
committed
feat: adding a patch file syncing script
1 parent dca2d73 commit b677aaf

File tree

13 files changed

+530
-2
lines changed

13 files changed

+530
-2
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,5 @@ refs
5757
migration/temp
5858
migration/sync/data
5959
migration/sync-ts/data
60+
migration/sync-ts/vn-sync/uploads
61+
migration/sync-ts/patch

app/patch/[id]/introduction/page.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,7 @@ export default async function Kun({ params }: Props) {
3131
}
3232

3333
const nsfwEnable = await getNSFWHeader()
34-
const isNSFW =
35-
nsfwEnable.content_limit === 'nsfw' || nsfwEnable.content_limit === 'all'
34+
const isNSFW = nsfwEnable.content_limit !== 'sfw'
3635

3736
return <PatchDetailIntro isNSFW={isNSFW} detail={detail} />
3837
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import path from 'path'
2+
import { readdir, mkdir, copyFile, writeFile } from 'fs/promises'
3+
import { vndbGetVnById } from './api/vndb'
4+
import { parsePatchFileName } from './vn-sync/parse'
5+
import sharp from 'sharp'
6+
7+
async function ensureDir(dir: string) {
8+
await mkdir(dir, { recursive: true })
9+
}
10+
11+
function pickSfwScreenshotUrlLocal(vn: any): string | null {
12+
const shots = Array.isArray(vn?.screenshots) ? vn.screenshots : []
13+
const clean = shots.filter(
14+
(s: any) => (s.sexual ?? 0) === 0 && (s.violence ?? 0) === 0
15+
)
16+
if (!clean.length) return null
17+
clean.sort((a: any, b: any) => (b.votecount ?? 0) - (a.votecount ?? 0))
18+
return clean[0]?.url || null
19+
}
20+
21+
export async function syncPatchesToS3Dry(
22+
srcDir = 'migration/sync-ts/patch',
23+
dstPatchDir = 'migration/sync-ts/vn-sync/uploads/patch',
24+
dstBannerDir = 'migration/sync-ts/vn-sync/uploads/banner'
25+
) {
26+
await ensureDir(dstPatchDir)
27+
await ensureDir(dstBannerDir)
28+
const files = await readdir(srcDir)
29+
const results: Array<{ file: string; ok: boolean; error?: string }> = []
30+
for (const name of files) {
31+
const filePath = path.posix.join(srcDir, name)
32+
try {
33+
const parsed = parsePatchFileName(filePath)
34+
if (!parsed) {
35+
results.push({ file: name, ok: false, error: 'Unrecognized filename' })
36+
continue
37+
}
38+
// Copy patch file for inspection
39+
const dstPatchPath = path.posix.join(dstPatchDir, name)
40+
await copyFile(filePath, dstPatchPath)
41+
42+
// Fetch VN and write banner webp files
43+
const vn = await vndbGetVnById(parsed.vndbId)
44+
const shotUrl = pickSfwScreenshotUrlLocal(vn)
45+
if (shotUrl) {
46+
const res = await fetch(shotUrl)
47+
const ab = await res.arrayBuffer()
48+
const banner = await sharp(ab)
49+
.resize(1920, 1080, { fit: 'inside', withoutEnlargement: true })
50+
.webp({ quality: 70 })
51+
.toBuffer()
52+
const mini = await sharp(ab)
53+
.resize(460, 259, { fit: 'inside', withoutEnlargement: true })
54+
.webp({ quality: 70 })
55+
.toBuffer()
56+
const base = `${parsed.vndbId}`
57+
await writeFile(
58+
path.posix.join(dstBannerDir, `${base}-banner.webp`),
59+
banner
60+
)
61+
await writeFile(
62+
path.posix.join(dstBannerDir, `${base}-banner-mini.webp`),
63+
mini
64+
)
65+
}
66+
67+
results.push({ file: name, ok: true })
68+
} catch (e: any) {
69+
results.push({ file: name, ok: false, error: e?.message || String(e) })
70+
}
71+
}
72+
return results
73+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import path from 'path'
2+
import { readdir } from 'fs/promises'
3+
import { processOnePatchFile } from './vn-sync/index'
4+
5+
export async function syncPatchesToS3(dir = 'migration/sync-ts/patch') {
6+
const files = await readdir(dir)
7+
const results: Array<{ file: string; ok: boolean; error?: string }> = []
8+
for (const name of files) {
9+
const filePath = path.posix.join(dir, name)
10+
try {
11+
const res = await processOnePatchFile(filePath)
12+
if (typeof res === 'string') {
13+
results.push({ file: name, ok: false, error: res })
14+
} else {
15+
results.push({ file: name, ok: true })
16+
}
17+
} catch (e: any) {
18+
results.push({ file: name, ok: false, error: e?.message || String(e) })
19+
}
20+
}
21+
return results
22+
}

migration/sync-ts/vn-sync/index.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { prisma } from '../db/prisma'
2+
import { vndbGetVnById } from '../api/vndb'
3+
import { parsePatchFileName, type ParsedPatchFileName } from './parse'
4+
import { uploadBannerForPatchWebp, uploadPatchFileToS3 } from './upload'
5+
import { generateFileHash } from '../../../app/api/upload/calculateFileStreamHash'
6+
import { loadNoteTemplate, renderNoteFromTemplate } from './note'
7+
import sharp from 'sharp'
8+
9+
export async function pickSfwScreenshotUrl(vn: any): Promise<string | null> {
10+
if (!vn?.screenshots?.length) return null
11+
const clean = vn.screenshots.filter(
12+
(s: any) => (s.sexual ?? 0) === 0 && (s.violence ?? 0) === 0
13+
)
14+
if (!clean.length) return null
15+
// prefer highest vote count
16+
clean.sort((a: any, b: any) => (b.votecount ?? 0) - (a.votecount ?? 0))
17+
return clean[0].url || null
18+
}
19+
20+
function deriveJaTitle(vn: any): string {
21+
try {
22+
const titles = Array.isArray(vn?.titles) ? vn.titles : []
23+
const jaItem = titles.find(
24+
(t: any) => String(t?.lang || '').split('-')[0] === 'ja'
25+
)
26+
if (jaItem?.title) return String(jaItem.title)
27+
const olang = String(vn?.olang || '').toLowerCase()
28+
if (olang === 'ja' && vn?.alttitle) return String(vn.alttitle)
29+
} catch {}
30+
return ''
31+
}
32+
33+
export async function createPatchIfMissing(parsed: ParsedPatchFileName) {
34+
const existing = await prisma.patch.findFirst({
35+
where: { vndb_id: parsed.vndbId },
36+
select: { id: true }
37+
})
38+
if (existing) return existing.id
39+
40+
const vn = await vndbGetVnById(parsed.vndbId)
41+
const nameEn = (vn as any)?.title || ''
42+
const nameJa = parsed.gameName || deriveJaTitle(vn)
43+
const releasedRaw = String((vn as any)?.released || '').trim()
44+
const released = releasedRaw ? formatDateYMD(releasedRaw) : 'unknown'
45+
46+
const patch = await prisma.patch.create({
47+
data: {
48+
name: '',
49+
name_en_us: nameEn || '',
50+
name_ja_jp: nameJa || '',
51+
vndb_id: parsed.vndbId,
52+
user_id: 1,
53+
banner: '',
54+
released,
55+
content_limit: 'sfw',
56+
type: [],
57+
language: [],
58+
engine: [],
59+
platform: []
60+
}
61+
})
62+
63+
// Upload banner if possible
64+
try {
65+
const screenshotUrl = await pickSfwScreenshotUrl(vn)
66+
if (screenshotUrl) {
67+
const res = await fetch(screenshotUrl)
68+
const arrayBuffer = await res.arrayBuffer()
69+
const uploadRes = await uploadBannerForPatchWebp(patch.id, arrayBuffer)
70+
if (typeof uploadRes !== 'string') {
71+
await prisma.patch.update({
72+
where: { id: patch.id },
73+
data: { banner: uploadRes.link }
74+
})
75+
}
76+
}
77+
} catch {}
78+
79+
return patch.id
80+
}
81+
82+
export function formatSizeString(bytes: number): string {
83+
const GB = 1024 * 1024 * 1024
84+
const MB = 1024 * 1024
85+
if (bytes >= GB) return `${(bytes / GB).toFixed(3)}GB`
86+
return `${(bytes / MB).toFixed(3)}MB`
87+
}
88+
89+
export async function createPatchResourceForFile(
90+
patchId: number,
91+
parsed: ParsedPatchFileName
92+
) {
93+
const hash = await generateFileHash(parsed.filePath)
94+
const uploaded = await uploadPatchFileToS3(patchId, hash, parsed.filePath)
95+
if (typeof uploaded === 'string') return uploaded
96+
97+
const size = (await (await import('fs/promises')).stat(parsed.filePath)).size
98+
const sizeStr = formatSizeString(size)
99+
const noteTpl = await loadNoteTemplate()
100+
const note = renderNoteFromTemplate(noteTpl, {
101+
company: parsed.company,
102+
gameName: parsed.gameName,
103+
groupName: parsed.groupName,
104+
language: parsed.language,
105+
publishDate: formatDateYMD(parsed.publishDate),
106+
startDate: formatDateYMD(parsed.startDate),
107+
vndbId: parsed.vndbId,
108+
platform: parsed.platform,
109+
fileName: parsed.fileName
110+
})
111+
112+
const resource = await prisma.patch_resource.create({
113+
data: {
114+
patch_id: patchId,
115+
user_id: 9147,
116+
storage: 's3',
117+
name: '',
118+
model_name: '',
119+
localization_group_name: parsed.groupName,
120+
size: sizeStr,
121+
code: '',
122+
password: '',
123+
note,
124+
hash,
125+
content: uploaded.url,
126+
type: ['manual'],
127+
language: [parsed.language],
128+
platform: [parsed.platform]
129+
}
130+
})
131+
132+
// union update to patch fields
133+
const p = await prisma.patch.findUnique({
134+
where: { id: patchId },
135+
select: { type: true, language: true, platform: true }
136+
})
137+
if (p) {
138+
const types = Array.from(new Set([...(p.type || []), 'manual']))
139+
const langs = Array.from(new Set([...(p.language || []), parsed.language]))
140+
const plats = Array.from(new Set([...(p.platform || []), parsed.platform]))
141+
await prisma.patch.update({
142+
where: { id: patchId },
143+
data: {
144+
resource_update_time: new Date(),
145+
type: { set: types },
146+
language: { set: langs },
147+
platform: { set: plats }
148+
}
149+
})
150+
}
151+
152+
return resource
153+
}
154+
155+
export async function processOnePatchFile(filePath: string) {
156+
const parsed = parsePatchFileName(filePath)
157+
if (!parsed) return `Unrecognized filename format: ${filePath}`
158+
const patchId = await createPatchIfMissing(parsed)
159+
return await createPatchResourceForFile(patchId, parsed)
160+
}
161+
162+
export function formatDateYMD(s: string) {
163+
const t = String(s || '').replace(/[^0-9]/g, '')
164+
if (t.length === 8) {
165+
return `${t.slice(0, 4)}-${t.slice(4, 6)}-${t.slice(6, 8)}`
166+
}
167+
if (t.length === 6) {
168+
return `${t.slice(0, 4)}-${t.slice(4, 6)}`
169+
}
170+
if (t.length === 4) return t
171+
return s
172+
}

migration/sync-ts/vn-sync/note.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{会社名} - {游戏名} 中文化补丁
2+
3+
由 {汉化组名} 开坑于 {汉化开坑日期}, 完成于 {汉化发布日期}
4+
5+
**本补丁由 [VN视觉小说汉化补丁遗产归档](https://www.moyu.moe/user/9147/resource) 归档**
6+
7+
{文件名}

migration/sync-ts/vn-sync/note.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { readFile } from 'fs/promises'
2+
3+
export interface NoteContext {
4+
company: string
5+
gameName: string
6+
groupName: string
7+
language: 'zh-Hans' | 'zh-Hant'
8+
publishDate: string
9+
startDate?: string
10+
vndbId: string
11+
platform: 'windows' | 'other'
12+
fileName?: string
13+
}
14+
15+
// Try to replace placeholders in the template like {会社名}, {游戏名}, {汉化组}, {汉化发布日期}, {语言}, {VNDB}, {平台}
16+
export function renderNoteFromTemplate(template: string, ctx: NoteContext) {
17+
const pairs: Array<[RegExp, string]> = [
18+
/{[^}]*[^}]*}/, // company (JP word)
19+
/{[^}]*[^}]*}/ // company (CN word)
20+
].map((rx) => [rx as RegExp, ctx.company]) as any
21+
22+
const more: Array<[RegExp, string]> = [
23+
[/{[^}]*[^}]*}/, ctx.gameName],
24+
[/{[^}]*[^}]*}/, ctx.groupName],
25+
[/{[^}]*[^}]*}/, ctx.groupName],
26+
[/{[^}]*[^}]*}/, ctx.startDate || ''],
27+
[/{[^}]*[^}]*}/, ctx.startDate || ''],
28+
[/{[^}]*[^}]*}/, ctx.startDate || ''],
29+
[/{[^}]*[^}]*}/, ctx.publishDate],
30+
[/{[^}]*[^}]*}/, ctx.language],
31+
[/{[^}]*VNDB[^}]*}/i, ctx.vndbId],
32+
[/{[^}]*[^}]*}/, ctx.platform],
33+
[/{[^}]*[^}]*}/, ctx.fileName || '']
34+
]
35+
36+
let out = template
37+
for (const [rx, val] of [...(pairs as any), ...more]) {
38+
out = out.replace(rx, val)
39+
}
40+
return out
41+
}
42+
43+
export async function loadNoteTemplate() {
44+
const buf = await readFile('migration/sync-ts/vn-sync/note.md')
45+
return buf.toString('utf8')
46+
}

0 commit comments

Comments
 (0)