Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/nichedb",
"version": "0.7.2",
"version": "0.7.3",
"description": "CLI and MCP bridge for NicheDB: browse collections, manage sources and feeds, search items, from any deployment",
"type": "module",
"main": "src/index.js",
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { homedir } from 'node:os';
import { join } from 'node:path';
import { createInterface } from 'node:readline';

export const VERSION = '0.7.2';
export const VERSION = '0.7.3';
const DEFAULT_API = process.env.NICHEDB_API ?? 'https://nichedb.dev';
const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'nichedb');
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nichedb/web",
"version": "0.7.2",
"version": "0.7.3",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lib/mcp/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export const TOOLS = [
teams: sports ? parsed.teams : null,
league: parsed.league,
date: /^\d{4}-\d{2}-\d{2}$/.test(String(date ?? '')) ? date : null,
fallback: cleanChannelName(String(term)),
fallback: parsed.kind === 'fixture' ? cleanChannelName(String(term)) : null,
limit: Math.min(Number(limit) || 5, 50),
});
return {
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/routes/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,9 @@ export function registerApi(app) {
teams: sports ? parsed.teams : null,
league: parsed.league,
date,
fallback: cleanChannelName(raw),
// A matchup that finds no fixture is matched again as the name was
// written; a title keeps its cleaned name.
fallback: parsed.kind === 'fixture' ? cleanChannelName(raw) : null,
limit: lim(c.req.query('limit'), 5, 50),
});
c.header('cache-control', 'public, max-age=300');
Expand Down
2 changes: 1 addition & 1 deletion apps/worker/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nichedb/worker",
"version": "0.7.2",
"version": "0.7.3",
"private": true,
"type": "module",
"scripts": {
Expand Down
10 changes: 7 additions & 3 deletions docs/consolidation.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,13 @@ the best items for a name, by trigram similarity on `title`, each with a `score`
This is what nixamp asks with a file name or a playlist entry's name.

A matchup name ("NFL: Chiefs vs Bills", "Lakers @ Celtics", "Arsenal v Chelsea",
"Rangers at Celtic 19:45") parses as kind `fixture` with `teams: [A, B]` and
`league` (the label in front, or null), and is answered by team rather than by
title: every fixture kicking off between 36 hours ago and 7 days ahead (or on
"Rangers at Celtic 19:45", "NFL 01: 8:20PM Patriots vs Seahawks", "US (ESPN+ 017)
| MLB: Rays vs. Braves (ESP) (2026-09-10 11:30:10)") parses as kind `fixture`
with `teams: [A, B]` and `league` (the label in front with any channel number
dropped, or null for a channel name or a country code); the clocks, stamps and
tags a playlist adds are not a release year, though a year alone, "(2004)", still
makes a title (which keeps `teams`, so a fixture is tried first). It is answered
by team rather than by title: every fixture kicking off between 36 hours ago and 7 days ahead (or on
`date=YYYY-MM-DD` plus a day either side) whose title or abbreviation is near
either side is scored by whether each side equals, whole-word-contains or is
contained by `data.home`/`data.away`'s `displayName`, `name` or `abbreviation`
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "niche-db",
"version": "0.7.2",
"version": "0.7.3",
"private": true,
"type": "module",
"description": "An open platform for large, ever-growing databases of real-time data: sources in, feeds out. Web, API, CLI and MCP on one Postgres.",
Expand Down
2 changes: 1 addition & 1 deletion packages/adapters/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nichedb/adapters",
"version": "0.7.2",
"version": "0.7.3",
"private": true,
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/auth/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nichedb/auth",
"version": "0.7.2",
"version": "0.7.3",
"private": true,
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/config/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nichedb/config",
"version": "0.7.2",
"version": "0.7.3",
"private": true,
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nichedb/core",
"version": "0.7.2",
"version": "0.7.3",
"private": true,
"type": "module",
"exports": {
Expand Down
94 changes: 75 additions & 19 deletions packages/core/src/names.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,37 @@ const WHEN = `(?:${TIME}|${WEEKDAY}|${DATE}|live|hd|fhd|uhd|4k|sd|\\d{3,4}[pi]|t
const TRAILING_WHEN = new RegExp(String.raw`[\s,|@()-]+${WHEN}\s*$`, 'i');
const LEADING_WHEN = new RegExp(String.raw`^${WHEN}[\s,|@:()-]+(?=\S)`, 'i');

/**
* What a playlist hangs on the end of an event name: "(ESP) (2026-09-10
* 11:30:10)", "(HD)", "(20:00)", or a bare stamp. A year on its own, "(2004)",
* is how a title is written and stays.
*/
const TRAILING_EVENT_TAG =
/\s*\((?:\d{4}-\d{2}-\d{2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?|\d{1,2}[:.]\d{2}(?::\d{2})?\s*(?:[ap]\.?m\.?)?(?:\s*[a-z]{2,4})?|\d{3,4}[pi]|\p{L}{2,10}\+?)\)\s*$/iu;
const TRAILING_EVENT_STAMP = /\s+\d{4}-\d{2}-\d{2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?\s*$/;

/** The name without the stamps and tags a playlist appends to an event. */
export function stripEventTags(raw) {
let s = String(raw ?? '').trim();
for (let i = 0; i < 6; i++) {
const next = s.replace(TRAILING_EVENT_TAG, '').replace(TRAILING_EVENT_STAMP, '').trim();
if (next === s) break;
s = next;
}
return s;
}

/** A clock, a day or a decoration at either end, taken off until none is left. */
const stripWhen = (text) => {
let s = text;
for (let i = 0; i < 4; i++) {
const next = s.replace(TRAILING_WHEN, '').replace(LEADING_WHEN, '').trim();
if (next === s) break;
s = next;
}
return s;
};

const SIDE_SEPARATOR = /\s+(?:vs\.?|v\.?|at|@)\s+/i;
const DASH_SEPARATOR = /\s+[-–—]\s+/;
/** "NFL: ", "NBA | ": a short label before a colon or a bar. */
Expand Down Expand Up @@ -103,10 +134,21 @@ const looksLikeTeam = (side) => {
return !quality;
};

/** The league label as written, upper-cased when it is an acronym; null for a country code. */
/** A label that names the channel carrying the game, not the league: "ESPN+ 017", "Sky Sports 3". */
const CHANNEL_LABEL =
/\b(sports?|tv|channel|espn|sky|fox|tnt|bein|dazn|tsn|bt|hd|fhd|uhd|4k|live|feed)\b|\+/i;

/**
* The league label as written, upper-cased when it is an acronym. A trailing
* channel number comes off ("NFL 01" is NFL; "Ligue 1" keeps its single
* digit); a country code, a bare number or a channel name is no league.
*/
const leagueOf = (label) => {
const s = label.trim().replace(/\s+/g, ' ');
if (!s || COUNTRY_CODE.test(s)) return null;
const s = label
.trim()
.replace(/\s+/g, ' ')
.replace(/\s+\d{2,4}$/, '');
if (!s || COUNTRY_CODE.test(s) || /^\d+$/.test(s) || CHANNEL_LABEL.test(s)) return null;
return /^[a-z0-9]{2,5}$/i.test(s) ? s.toUpperCase() : s;
};

Expand All @@ -128,18 +170,16 @@ export function parseMatchup(raw) {
.replace(/\s+/g, ' ')
.trim();
if (!s) return null;
for (let i = 0; i < 4; i++) {
const next = s.replace(TRAILING_WHEN, '').replace(LEADING_WHEN, '').trim();
if (next === s) break;
s = next;
}
s = stripWhen(s);
let league = null;
// "US: NFL: Chiefs vs Bills": the last label before the sides is the league.
// "NFL 01: 8:20PM Patriots vs Seahawks": the clock behind a label is a
// clock, not the next label, so it comes off before the next one is read.
for (let i = 0; i < 3; i++) {
const m = s.match(LEAGUE_COLON);
if (!m) break;
league = leagueOf(m[1]) ?? league;
s = s.slice(m[0].length);
s = stripWhen(s.slice(m[0].length));
}
// "EPL - Arsenal v Chelsea", "EPL - Arsenal - Chelsea": a label before a
// dash is the league only when what follows is still a matchup; "Arsenal -
Expand Down Expand Up @@ -233,15 +273,19 @@ export function cleanChannelName(raw) {
* A matchup ("NFL: Chiefs vs Bills", "Rangers at Celtic 19:45") is a fixture:
* `teams` holds the two sides as written and `league` the label in front of
* them when there was one, so the match can score by team rather than by
* title.
* title. The stamps a playlist appends, "(ESP) (2026-09-10 11:30:10)", are not
* a release year. A year on its own, "Alien vs Predator (2004)", is a title,
* and still carries `teams` so a fixture is tried before the title is.
*
* @returns {{ raw: string, name: string, year: number|null, season: number|null,
* episode: number|null, kind: 'movie'|'series'|'channel'|'music'|'fixture'|'other',
* teams: [string, string]|null, league: string|null }}
*/
export function parseName(raw) {
const text = String(raw ?? '').trim();
const year = extractYear(text);
// "(2026-09-10 11:30:10)" behind an event is a stamp, not a release year.
const bare = stripEventTags(text);
const year = extractYear(bare);
let season = null;
let episode = null;
for (const re of EPISODE) {
Expand All @@ -264,11 +308,8 @@ export function parseName(raw) {
const series = season !== null || episode !== null || /\bcomplete\s+series\b/i.test(text);
// A year, a file ending or a codec makes a release. A bare resolution does
// not: a playlist writes "TF1 (720p)" and means the channel, not a rip.
const releaseLike =
series ||
year !== null ||
EXTENSIONS.test(text) ||
/\b(x264|x265|hevc|web-?dl|webrip|bluray|bdrip|brrip|hdtv|dvdrip|remux)\b/i.test(text);
const codec = /\b(x264|x265|hevc|web-?dl|webrip|bluray|bdrip|brrip|hdtv|dvdrip|remux)\b/i;
const releaseLike = series || year !== null || EXTENSIONS.test(bare) || codec.test(bare);
const music =
/\b(flac|320kbps|discography|album|ost|soundtrack|\.mp3)\b/i.test(text) ||
/\.(mp3|flac|m4a|aac|ogg|opus|wav)$/i.test(text);
Expand All @@ -277,12 +318,27 @@ export function parseName(raw) {
let kind;
let teams = null;
let league = null;
const matchup = releaseLike || music ? null : parseMatchup(text);
// A year alone in parentheses is how a title is written, but the sides are
// still read, so "MLB: Rays vs Braves (2026)" is a fixture and "Alien vs
// Predator (2004)" a title that a fixture is looked for first.
const yearOnly =
releaseLike &&
!series &&
/\((19|20)\d{2}\)/.test(bare) &&
!EXTENSIONS.test(bare) &&
!codec.test(bare);
let matchup = null;
if (!music && !series) {
if (!releaseLike) matchup = parseMatchup(bare);
else if (yearOnly) matchup = parseMatchup(bare.replace(/\((19|20)\d{2}\)/g, ' '));
}
if (matchup) {
name = matchup.name;
kind = 'fixture';
teams = matchup.teams;
league = matchup.league;
}
if (matchup && (!releaseLike || matchup.league)) {
name = matchup.name;
kind = 'fixture';
} else if (releaseLike || music) {
name = cleanReleaseName(text);
if (year !== null) name = name.replace(new RegExp(`\\b${year}\\b`), '').trim();
Expand Down
2 changes: 1 addition & 1 deletion packages/db/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nichedb/db",
"version": "0.7.2",
"version": "0.7.3",
"private": true,
"type": "module",
"exports": {
Expand Down
36 changes: 31 additions & 5 deletions packages/db/src/matchups.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,23 @@ const CLUB_WORDS = new Set([
'as',
]);

/**
* The short form both a side and a team name are brought to before they are
* compared, so "Alcorn St" meets "Alcorn State Braves" and "Man Utd" meets
* "Manchester United" whichever way round each was written.
*/
const SHORT = new Map([
['state', 'st'],
['saint', 'st'],
['university', 'univ'],
['united', 'utd'],
['manchester', 'man'],
['athletic', 'ath'],
['atletico', 'atl'],
['internazionale', 'inter'],
['juventus', 'juve'],
]);

/** Lower case, diacritics folded, punctuation to spaces. */
export function foldName(value) {
return String(value ?? '')
Expand All @@ -86,6 +103,15 @@ export function expandSide(side) {
.trim();
}

/** Folded, club suffixes dropped, long words shortened: what is compared. */
export function canonName(value) {
return foldName(value)
.split(' ')
.filter((t) => t && !CLUB_WORDS.has(t))
.map((t) => SHORT.get(t) ?? t)
.join(' ');
}

const wholeWordIn = (needle, haystack) =>
needle.length > 0 && ` ${haystack} `.includes(` ${needle} `);

Expand All @@ -97,16 +123,16 @@ const wholeWordIn = (needle, haystack) =>
*/
export function sideMatchesTeam(side, team) {
if (!team) return false;
const raw = foldName(side);
const expanded = expandSide(side);
const raw = canonName(side);
const expanded = canonName(expandSide(side));
if (!raw) return false;
// The abbreviation only ever matches whole: "MAN" is Manchester United, but
// "Man City" is not.
const abbreviation = foldName(team.abbreviation);
if (abbreviation && (abbreviation === raw || abbreviation === expanded)) return true;
if (abbreviation && (abbreviation === raw || abbreviation === foldName(side))) return true;
const fields = [team.displayName, team.name, team.shortName, team.location]
.filter(Boolean)
.map(foldName)
.map(canonName)
.filter(Boolean);
for (const f of fields) {
if (f === raw || f === expanded) return true;
Expand All @@ -117,7 +143,7 @@ export function sideMatchesTeam(side, team) {
if (raw.length >= 3 && wholeWordIn(f, raw)) return true;
if (expanded.length >= 3 && wholeWordIn(f, expanded)) return true;
}
const words = new Set(longFields.flatMap((f) => f.split(' ')).filter((w) => !CLUB_WORDS.has(w)));
const words = new Set(longFields.flatMap((f) => f.split(' ')));
const tokens = expanded.split(' ').filter(Boolean);
return (
tokens.length > 0 && tokens.some((t) => t.length >= 4) && tokens.every((t) => words.has(t))
Expand Down
2 changes: 1 addition & 1 deletion packages/enrichers/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nichedb/enrichers",
"version": "0.7.2",
"version": "0.7.3",
"private": true,
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/knowledge/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nichedb/knowledge",
"version": "0.7.2",
"version": "0.7.3",
"private": true,
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/notify/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nichedb/notify",
"version": "0.7.2",
"version": "0.7.3",
"private": true,
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/payments/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nichedb/payments",
"version": "0.7.2",
"version": "0.7.3",
"private": true,
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/queue/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nichedb/queue",
"version": "0.7.2",
"version": "0.7.3",
"private": true,
"type": "module",
"exports": {
Expand Down
Loading
Loading