import { Brackets } from "typeorm"; import { Notes } from "../../../../models/index.js"; import define from "../../define.js"; import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; function hasTag(note, tag) { return note.tags?.some((x)=>x.toLowerCase() === tag.toLowerCase()) ?? false; } function searchableText(file) { return `${file.name} ${file.comment ?? ""}`.toLowerCase(); } function isMidiFile(file) { const text = searchableText(file); const type = file.type.toLowerCase(); return text.endsWith(".mid") || text.endsWith(".midi") || type === "audio/midi" || type === "audio/mid" || type === "audio/x-midi" || type === "audio/x-mid" || type === "application/midi" || type === "application/x-midi"; } export function classifyKaraokeFile(file) { const text = searchableText(file); if (file.type.startsWith("video/")) return "video"; if (isMidiFile(file)) return "pitch"; if (text.includes("score") || text.includes("scoring") || text.includes("採点")) return "scoring"; if (text.includes("pitch") || text.includes("tempo") || text.includes("melody") || text.includes("part") || text.includes("音程") || text.includes("テンポ") || text.includes("パート")) return "pitch"; if (text.includes("lyrics") || text.includes("lyric") || text.endsWith(".lrc") || text.includes("歌詞")) return "lyrics"; if (file.type.startsWith("audio/")) return "accompaniment"; if (file.type.startsWith("text/") && (text.includes("info") || text.includes("metadata") || text.includes("song") || text.includes("楽曲情報"))) return "info"; return null; } function isKaraokeServiceNote(note) { if (![ "public", "home", "followers" ].includes(note.visibility)) return false; if (!hasTag(note, "KaraokeService")) return false; const counts = { accompaniment: 0, lyrics: 0, pitch: 0, scoring: 0, video: 0, info: 0 }; const fallbackDataFiles = []; for (const file of note.files ?? []){ const role = classifyKaraokeFile(file); if (role == null) { fallbackDataFiles.push(file); continue; } counts[role]++; } for (const role of [ "lyrics", "pitch", "scoring" ]){ if (counts[role] === 0 && fallbackDataFiles.length > 0) { fallbackDataFiles.shift(); counts[role]++; } } return counts.accompaniment === 1 && counts.lyrics === 1 && counts.pitch === 1 && counts.scoring === 1 && counts.video <= 1 && counts.info <= 1 && fallbackDataFiles.length === 0; } export const meta = { tags: [ "notes" ], requireCredential: false, requireCredentialPrivateMode: true, res: { type: "array", optional: false, nullable: false, items: { type: "object", optional: false, nullable: false, ref: "Note" } } }; export const paramDef = { type: "object", properties: { query: { type: "string", default: "" }, limit: { type: "integer", minimum: 1, maximum: 50, default: 20 }, offset: { type: "integer", minimum: 0, default: 0 } }, required: [] }; export default define(meta, paramDef, async (ps, user)=>{ const queryText = ps.query.trim(); const query = Notes.createQueryBuilder("note").innerJoinAndSelect("note.user", "user").leftJoinAndSelect("note.reply", "reply").leftJoinAndSelect("note.renote", "renote").leftJoinAndSelect("reply.user", "replyUser").leftJoinAndSelect("renote.user", "renoteUser").where(`'{"karaokeservice"}' <@ note.tags`).andWhere("note.fileIds != '{}'"); generateVisibilityQuery(query, user); if (user) generateMutedUserQuery(query, user); if (user) generateBlockedUserQuery(query, user); if (queryText.length > 0) { query.andWhere(new Brackets((qb)=>{ qb.where("note.text ILIKE :query", { query: `%${queryText}%` }).orWhere("user.usernameLower ILIKE :query", { query: `%${queryText.toLowerCase()}%` }); })); } const notes = await query.orderBy("note.createdAt", "DESC").skip(ps.offset).take(ps.limit * 4).getMany(); const packed = await Notes.packMany(notes, user); return packed.filter((note)=>isKaraokeServiceNote(note)).slice(0, ps.limit); });