Files
QueueCube/web/backend/src/MediaPlayer.ts

724 lines
26 KiB
TypeScript
Raw Normal View History

2025-04-24 16:24:22 -07:00
/*
* MediaPlayer.ts
* Copyleft 2025 James Magahern <buzzert@buzzert.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published
* by the Free Software Foundation, either version 3 of the License,
* or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
2025-02-15 00:37:32 -08:00
import { ChildProcess, spawn } from "child_process";
import { Socket } from "net";
import { WebSocket } from "ws";
import { getLinkPreview } from "link-preview-js";
2025-02-23 13:46:31 -08:00
import { PlaylistItem, LinkMetadata } from './types';
import { FavoritesStore } from "./FavoritesStore";
2025-05-30 20:12:53 -07:00
import { Bonjour } from "bonjour-service";
2025-06-07 11:39:56 -07:00
import os from 'os';
2025-02-23 16:37:39 -08:00
2025-02-15 00:37:32 -08:00
interface PendingCommand {
resolve: (value: any) => void;
reject: (reason: any) => void;
}
enum ObservedProperty {
MediaTitle = 1,
Metadata = 2,
}
2025-02-23 16:37:39 -08:00
enum UserEvent {
PlaylistUpdate = "playlist_update",
NowPlayingUpdate = "now_playing_update",
VolumeUpdate = "volume_update",
FavoritesUpdate = "favorites_update",
MetadataUpdate = "metadata_update",
MPDUpdate = "mpd_update",
2025-11-15 17:58:29 -08:00
PlaybackError = "playback_error",
2025-02-23 16:37:39 -08:00
}
export interface Features {
video: boolean;
screenshare: boolean;
browserPlayback: boolean;
}
2025-02-15 00:37:32 -08:00
export class MediaPlayer {
private playerProcess: ChildProcess | null = null;
private socket: Promise<Socket>;
2025-02-15 00:37:32 -08:00
private eventSubscribers: WebSocket[] = [];
2025-02-23 13:46:31 -08:00
private favoritesStore: FavoritesStore;
2025-02-15 00:37:32 -08:00
private pendingCommands: Map<number, PendingCommand> = new Map();
private requestId: number = 1;
private dataBuffer: string = '';
private metadata: Map<string, LinkMetadata> = new Map();
2025-05-30 20:12:53 -07:00
private bonjourInstance: Bonjour | null = null;
2025-11-15 17:58:29 -08:00
private playbackErrors: Map<string, string> = new Map();
private currentFile: string | null = null;
private lastLoadCandidate: string | null = null;
private currentMediaTitle: string | null = null;
private currentPlaybackMetadata: Record<string, string> = {};
2025-02-15 00:37:32 -08:00
constructor() {
this.socket = this.tryRespawnPlayerProcess();
this.favoritesStore = new FavoritesStore();
this.favoritesStore.onFavoritesChanged = (favorites) => {
this.handleEvent(UserEvent.FavoritesUpdate, { favorites });
};
this.getFeatures().then(features => {
console.log("Features: ", features);
});
}
2025-05-30 20:12:53 -07:00
public startZeroconfService(port: number) {
if (this.bonjourInstance) {
console.log("Zeroconf service already running");
return;
}
this.bonjourInstance = new Bonjour();
const service = this.bonjourInstance.publish({
2025-06-07 11:39:56 -07:00
name: `QueueCube Media Server (${os.hostname()})`,
2025-05-30 20:12:53 -07:00
type: 'queuecube',
port: port,
txt: {
version: '1.0.0',
features: 'playlist,favorites,screenshare'
}
});
service.on('up', () => {
console.log(`Zeroconf service advertised: ${service.name} on port ${port}`);
});
service.on('error', (err: Error) => {
console.error('Zeroconf service error:', err);
});
}
public stopZeroconfService() {
if (this.bonjourInstance) {
this.bonjourInstance.destroy();
this.bonjourInstance = null;
console.log("Zeroconf service stopped");
}
}
private tryRespawnPlayerProcess(): Promise<Socket> {
2025-02-15 02:19:14 -08:00
const socketFilename = Math.random().toString(36).substring(2, 10);
const socketPath = `/tmp/mpv-${socketFilename}`;
2025-02-23 18:17:27 -08:00
const enableVideo = process.env.ENABLE_VIDEO || false;
2026-05-28 23:00:10 -07:00
const ytdlFormat = process.env.MPV_YTDL_FORMAT;
const logfilePath = `/tmp/mpv-logfile.txt`;
2026-05-28 23:00:10 -07:00
const playerArgs = [
2025-02-23 18:17:27 -08:00
"--video=" + (enableVideo ? "auto" : "no"),
"--fullscreen",
2025-02-15 00:37:32 -08:00
"--no-terminal",
"--idle=yes",
"--input-ipc-server=" + socketPath,
"--log-file=" + logfilePath,
"--msg-level=all=v"
2026-05-28 23:00:10 -07:00
];
if (ytdlFormat) {
playerArgs.push("--ytdl-format=" + ytdlFormat);
}
console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")");
this.playerProcess = spawn("mpv", playerArgs);
2025-02-15 00:37:32 -08:00
let socketReady!: (s: Socket) => void;
let socketFailed!: (reason?: unknown) => void;
let socketPromise = new Promise<Socket>((resolve, reject) => {
socketReady = resolve;
socketFailed = reject;
});
2025-02-15 00:37:32 -08:00
this.playerProcess.on("spawn", () => {
2025-02-15 02:19:14 -08:00
console.log(`Player process spawned, opening socket @ ${socketPath}`);
this.connectToSocket(socketPath)
.then(socket => socketReady(socket))
.catch((error: unknown) => {
console.error(`Failed to connect to mpv socket @ ${socketPath}:`, error);
console.log("Continuing without mpv player...");
socketFailed(error);
});
2025-02-15 00:37:32 -08:00
});
2025-02-23 13:46:31 -08:00
this.playerProcess.on("error", (error: unknown) => {
2025-05-30 20:12:53 -07:00
console.error("Player process error:", error);
console.log("Continuing without mpv player...");
socketFailed(error);
2025-05-30 20:12:53 -07:00
});
return socketPromise;
2025-02-15 00:37:32 -08:00
}
2025-02-15 22:15:59 -08:00
public async getPlaylist(): Promise<PlaylistItem[]> {
2025-02-15 00:37:32 -08:00
return this.writeCommand("get_property", ["playlist"])
.then((response) => {
// Enhance playlist items with metadata
2025-02-15 22:15:59 -08:00
const playlist = response.data as PlaylistItem[];
return playlist.map((item: PlaylistItem) => {
const enhancedItem = {
...item,
metadata: this.metadata.get(item.filename) || {},
playbackError: this.playbackErrors.get(item.filename)
};
return item.current ? this.withCurrentDynamicTitle(enhancedItem) : enhancedItem;
});
2025-02-15 00:37:32 -08:00
});
}
2025-02-15 22:15:59 -08:00
public async getNowPlaying(): Promise<PlaylistItem> {
const playlist = await this.getPlaylist();
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
2025-06-26 01:38:12 -07:00
const fetchMediaTitle = async (): Promise<string | null> => {
try {
const mediaTitle = (await this.writeCommand("get_property", ["media-title"])).data;
this.currentMediaTitle = typeof mediaTitle === "string" ? mediaTitle : null;
return this.currentMediaTitle;
2025-06-26 01:38:12 -07:00
} catch (err) {
return null;
}
2025-02-15 22:15:59 -08:00
};
const mediaTitle = await fetchMediaTitle();
2025-02-15 22:15:59 -08:00
if (currentlyPlayingSong !== undefined) {
const dynamicTitle = this.getCurrentDynamicTitle(currentlyPlayingSong);
if (dynamicTitle) {
return this.withCurrentDynamicTitle(currentlyPlayingSong, dynamicTitle);
}
2025-02-15 22:15:59 -08:00
// Use media title if we don't have a title
if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) {
return {
...currentlyPlayingSong,
title: mediaTitle || currentlyPlayingSong.filename
2025-02-15 22:15:59 -08:00
};
}
return currentlyPlayingSong;
}
return {
id: 0,
filename: mediaTitle || "",
title: mediaTitle || ""
2025-02-15 22:15:59 -08:00
};
2025-02-15 00:37:32 -08:00
}
2025-06-26 01:38:12 -07:00
public async getCurrentFile(): Promise<string | null> {
2025-02-15 02:37:17 -08:00
return this.writeCommand("get_property", ["stream-open-filename"])
.then((response) => {
return response.data;
2025-06-26 01:38:12 -07:00
}, (reject) => { return null; });
2025-02-15 02:37:17 -08:00
}
2025-02-15 00:37:32 -08:00
public async getPauseState(): Promise<boolean> {
return this.writeCommand("get_property", ["pause"])
.then((response) => {
return response.data;
});
}
public async getVolume(): Promise<number> {
return this.writeCommand("get_property", ["volume"])
.then((response) => {
return response.data;
});
}
2025-06-26 01:38:12 -07:00
public async getTimePosition(): Promise<number | null> {
return this.writeCommand("get_property", ["time-pos"])
.then((response) => {
return response.data;
}, (rejected) => { return null; });
}
public async getDuration(): Promise<number | null> {
return this.writeCommand("get_property", ["duration"])
.then((response) => {
return response.data;
}, (rejected) => { return null; });
}
public async getSeekable(): Promise<boolean | null> {
return this.writeCommand("get_property", ["seekable"])
.then((response) => {
return response.data;
}, (rejected) => { return null; });
}
2025-02-15 00:37:32 -08:00
public async getIdle(): Promise<boolean> {
return this.writeCommand("get_property", ["idle"])
.then((response) => {
return response.data;
});
}
public async append(url: string) {
2025-02-23 13:46:31 -08:00
await this.loadFile(url, "append-play");
}
public async replace(url: string) {
await this.loadFile(url, "replace");
2025-02-15 00:37:32 -08:00
}
2025-05-12 20:27:09 -07:00
public async initiateScreenSharing(url: string) {
console.log(`Initiating screen sharing with file: ${url}`);
this.metadata.set(url, {
title: "Screen Sharing",
description: "Screen Sharing",
siteName: "Screen Sharing",
});
// Special options for mpv to better handle screen sharing (AI recommended...)
await this.loadFile(url, "replace", false, [
"demuxer-lavf-o=fflags=+nobuffer+discardcorrupt", // Reduce buffering and discard corrupt frames
"demuxer-lavf-o=analyzeduration=100000", // Reduce analyze duration
"demuxer-lavf-o=probesize=1000000", // Reduce probe size
"untimed=yes", // Ignore timing info
"cache=no", // Disable cache
"force-seekable=yes", // Force seekable
"no-cache=yes", // Disable cache
"demuxer-max-bytes=500K", // Limit demuxer buffer
"demuxer-readahead-secs=0.1", // Reduce readahead
"hr-seek=no", // Disable high-res seeking
"video-sync=display-resample", // Better sync mode
"video-latency-hacks=yes", // Enable latency hacks
"audio-sync=yes", // Enable audio sync
"audio-buffer=0.1", // Reduce audio buffer
"audio-channels=stereo", // Force stereo audio
"audio-samplerate=44100", // Match sample rate
"audio-format=s16", // Use 16-bit audio
]);
// Make sure it's playing
setTimeout(() => this.play(), 100);
}
2025-02-15 00:37:32 -08:00
public async play() {
2025-02-23 16:37:39 -08:00
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", false]));
2025-02-15 00:37:32 -08:00
}
public async pause() {
2025-02-23 16:37:39 -08:00
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", true]));
2025-02-15 02:37:17 -08:00
}
2025-04-20 01:23:34 -07:00
public async stop() {
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("stop", []));
}
2025-02-15 02:37:17 -08:00
public async skip() {
2025-02-23 16:37:39 -08:00
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-next", []));
2025-02-15 02:37:17 -08:00
}
2025-02-15 16:28:47 -08:00
public async skipTo(index: number) {
2025-02-23 16:37:39 -08:00
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-play-index", [index]));
2025-02-15 16:28:47 -08:00
}
2025-02-15 02:37:17 -08:00
public async previous() {
2025-02-23 16:37:39 -08:00
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-prev", []));
2025-02-15 00:37:32 -08:00
}
public async deletePlaylistItem(index: number) {
2025-02-23 16:37:39 -08:00
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-remove", [index]));
2025-02-15 02:37:17 -08:00
}
public async setVolume(volume: number) {
2025-02-23 16:37:39 -08:00
return this.modify(UserEvent.VolumeUpdate, () => this.writeCommand("set_property", ["volume", volume]));
2025-02-15 00:37:32 -08:00
}
2025-06-26 01:38:12 -07:00
public async seek(time: number) {
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("seek", [time, "absolute"]));
}
2025-02-15 00:37:32 -08:00
public subscribe(ws: WebSocket) {
this.eventSubscribers.push(ws);
}
public unsubscribe(ws: WebSocket) {
this.eventSubscribers = this.eventSubscribers.filter(subscriber => subscriber !== ws);
}
2025-02-15 02:37:17 -08:00
2025-02-23 13:46:31 -08:00
public async getFavorites(): Promise<PlaylistItem[]> {
return this.favoritesStore.getFavorites();
}
2025-02-23 18:04:14 -08:00
public async addFavorite(filename: string) {
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.addFavorite(filename));
2025-02-23 13:46:31 -08:00
}
public async removeFavorite(filename: string) {
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.removeFavorite(filename));
2025-02-23 13:46:31 -08:00
}
public async clearFavorites() {
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.clearFavorites());
2025-02-23 13:46:31 -08:00
}
2025-03-05 00:10:23 -08:00
public async updateFavoriteTitle(filename: string, title: string) {
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.updateFavoriteTitle(filename, title));
2025-03-05 00:10:23 -08:00
}
public async getFeatures(): Promise<Features> {
return {
video: !!process.env.ENABLE_VIDEO,
screenshare: !!process.env.ENABLE_SCREENSHARE,
browserPlayback: !!process.env.ENABLE_BROWSER_PLAYBACK
};
}
2025-05-12 20:27:09 -07:00
private async loadFile(url: string, mode: string, fetchMetadata: boolean = true, options: string[] = []) {
2025-11-15 17:58:29 -08:00
this.lastLoadCandidate = url;
this.playbackErrors.delete(url);
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')]));
2025-02-23 13:46:31 -08:00
2025-05-12 20:27:09 -07:00
if (fetchMetadata) {
this.fetchMetadataAndNotify(url).catch(error => {
console.warn(`Failed to fetch metadata for ${url}:`, error);
});
}
2025-02-23 13:46:31 -08:00
}
2025-02-23 16:37:39 -08:00
private async modify<T>(event: UserEvent, func: () => Promise<T>): Promise<T> {
2025-02-15 02:37:17 -08:00
return func()
.then((result) => {
// Notify all subscribers
2025-02-23 16:37:39 -08:00
this.handleEvent(event, {});
2025-02-15 02:37:17 -08:00
return result;
2025-06-26 01:38:12 -07:00
}, (reject) => {
console.log("Error modifying playlist: " + reject);
return reject;
});
2025-02-15 02:37:17 -08:00
}
2025-02-15 00:37:32 -08:00
private async writeCommand(command: string, args: any[]): Promise<any> {
// Wait for socket to become available.
let socket = await this.socket;
2025-02-15 00:37:32 -08:00
return new Promise((resolve, reject) => {
const id = this.requestId++;
const commandObject = JSON.stringify({
command: [command, ...args],
request_id: id
});
try {
this.pendingCommands.set(id, { resolve, reject });
socket.write(commandObject + '\n');
} catch (e: any) {
console.error(`Error writing to socket: ${e}. Trying to respawn.`)
this.socket = this.tryRespawnPlayerProcess();
}
2025-02-15 00:37:32 -08:00
// Add timeout to prevent hanging promises
setTimeout(() => {
if (this.pendingCommands.has(id)) {
const pending = this.pendingCommands.get(id);
if (pending) {
pending.reject(new Error('Command timed out'));
this.pendingCommands.delete(id);
}
}
}, 5000);
});
}
private async fetchMetadataAndNotify(url: string) {
try {
2025-02-23 13:46:31 -08:00
console.log("Fetching metadata for " + url);
const metadata = await getLinkPreview(url);
this.metadata.set(url, {
title: (metadata as any)?.title,
description: (metadata as any)?.description,
siteName: (metadata as any)?.siteName,
});
2025-02-23 13:46:31 -08:00
console.log("Metadata fetched for " + url);
console.log(this.metadata.get(url));
// Notify clients that metadata has been updated
2025-02-23 16:37:39 -08:00
this.handleEvent(UserEvent.MetadataUpdate, {
url,
metadata: this.metadata.get(url)
});
} catch (error) {
throw error;
}
}
private connectToSocket(path: string): Promise<Socket> {
const retryDelayMs = 100;
const maxAttempts = 50;
return new Promise((resolve, reject) => {
type SocketError = Error & { code?: string };
const attemptConnection = (attempt: number) => {
const socket = new Socket();
const onError = (error: SocketError) => {
socket.removeAllListeners();
socket.destroy();
const shouldRetry = (error.code === "ENOENT" || error.code === "ECONNREFUSED") && attempt < maxAttempts;
if (shouldRetry) {
setTimeout(() => attemptConnection(attempt + 1), retryDelayMs);
return;
}
reject(error);
};
socket.once("connect", () => {
socket.removeListener("error", onError);
socket.on("data", (data: unknown) => this.receiveData(String(data)));
socket.on("error", (error: unknown) => {
console.error("MPV socket error:", error);
});
resolve(socket);
this.registerMpvObservers().catch((error: unknown) => {
console.error("Failed to register mpv observers:", error);
});
});
socket.once("error", onError);
socket.connect(path);
};
attemptConnection(1);
});
2025-02-15 00:37:32 -08:00
}
private async registerMpvObservers() {
await this.writeCommand("observe_property", [ObservedProperty.MediaTitle, "media-title"]);
await this.writeCommand("observe_property", [ObservedProperty.Metadata, "metadata"]);
}
2025-02-15 00:37:32 -08:00
private handleEvent(event: string, data: any) {
2025-03-05 00:10:23 -08:00
console.log("Event [" + event + "]: ", data);
2025-02-15 00:37:32 -08:00
// Notify all subscribers
this.eventSubscribers.forEach(subscriber => {
subscriber.send(JSON.stringify({ event, data }));
});
}
private receiveData(data: string) {
this.dataBuffer += data;
const lines = this.dataBuffer.split('\n');
// Keep last incomplete line in the buffer
this.dataBuffer = lines.pop() || '';
for (const line of lines) {
if (line.trim().length > 0) {
try {
const response = JSON.parse(line);
if (response.request_id) {
const pending = this.pendingCommands.get(response.request_id);
if (pending) {
2025-06-26 01:38:12 -07:00
if (response.error == "success") {
pending.resolve(response);
} else {
pending.reject(response.error);
}
2025-02-15 00:37:32 -08:00
this.pendingCommands.delete(response.request_id);
}
} else if (response.event) {
2025-11-15 17:58:29 -08:00
if (response.event === "start-file") {
// Clear any previous error for the file that is starting
const file = response.file || this.lastLoadCandidate;
if (file) {
this.currentFile = file;
this.playbackErrors.delete(file);
}
this.currentMediaTitle = null;
this.currentPlaybackMetadata = {};
} else if (response.event === "property-change") {
this.handlePropertyChange(response);
2025-11-15 17:58:29 -08:00
} else if (response.event === "end-file" && response.reason === "error") {
const file = response.file || this.currentFile || this.lastLoadCandidate || "Unknown file";
const errorMessage = response.error || response["file-error"] || "Unknown playback error";
this.playbackErrors.set(file, errorMessage);
this.handleEvent(UserEvent.PlaybackError, {
filename: file,
error: errorMessage
});
}
2025-02-23 16:37:39 -08:00
this.handleEvent(UserEvent.MPDUpdate, response);
2025-02-15 00:37:32 -08:00
} else {
console.log(response);
}
} catch (error) {
console.error('Error parsing JSON:', error);
}
}
}
}
private handlePropertyChange(response: any) {
const previousTitle = this.getCurrentDynamicTitle();
if (response.id === ObservedProperty.MediaTitle || response.name === "media-title") {
this.currentMediaTitle = typeof response.data === "string" ? response.data : null;
} else if (response.id === ObservedProperty.Metadata || response.name === "metadata") {
this.currentPlaybackMetadata = this.normalizeMpvMetadata(response.data);
}
const currentTitle = this.getCurrentDynamicTitle();
if (currentTitle !== previousTitle) {
this.handleEvent(UserEvent.MetadataUpdate, {
url: this.currentFile,
title: currentTitle,
metadata: this.currentPlaybackMetadata
});
}
}
private withCurrentDynamicTitle(item: PlaylistItem, dynamicTitle: string | null = this.getCurrentDynamicTitle(item)): PlaylistItem {
if (!dynamicTitle) {
return item;
}
return {
...item,
title: dynamicTitle,
metadata: {
...item.metadata,
title: dynamicTitle
}
};
}
private getCurrentDynamicTitle(item?: PlaylistItem): string | null {
if (this.currentMediaTitle && !this.isFallbackMediaTitle(this.currentMediaTitle, item?.filename)) {
return this.currentMediaTitle.trim();
}
return this.extractTitleFromMpvMetadata();
}
private extractTitleFromMpvMetadata(): string | null {
const titleKeys = new Set(["title", "icy-title", "icy_title", "streamtitle", "stream-title"]);
for (const key of Object.keys(this.currentPlaybackMetadata)) {
const normalizedKey = key.toLowerCase();
if (!titleKeys.has(normalizedKey)) {
continue;
}
const title = this.parseStreamTitle(this.currentPlaybackMetadata[key]);
if (title) {
return title;
}
}
return null;
}
private parseStreamTitle(value: string | undefined): string | null {
const trimmedValue = value?.trim();
if (!trimmedValue) {
return null;
}
const streamTitleMatch = trimmedValue.match(/StreamTitle='([^']*)'/i);
return (streamTitleMatch?.[1] || trimmedValue).trim() || null;
}
private normalizeMpvMetadata(data: unknown): Record<string, string> {
if (data === null || typeof data !== "object" || Array.isArray(data)) {
return {};
}
const metadata: Record<string, string> = {};
const rawMetadata = data as Record<string, unknown>;
for (const key of Object.keys(rawMetadata)) {
const value = rawMetadata[key];
if (value !== null && value !== undefined) {
metadata[key] = String(value);
}
}
return metadata;
}
private isFallbackMediaTitle(title: string, filename?: string): boolean {
const normalizedTitle = title.trim();
if (!normalizedTitle) {
return true;
}
const fallbackCandidates = new Set<string>();
if (filename) {
this.addFallbackTitleCandidates(fallbackCandidates, filename);
}
if (this.currentFile) {
this.addFallbackTitleCandidates(fallbackCandidates, this.currentFile);
}
return fallbackCandidates.has(normalizedTitle);
}
private addFallbackTitleCandidates(candidates: Set<string>, value: string) {
candidates.add(value);
try {
candidates.add(decodeURIComponent(value));
} catch {
// Keep the original value if it is not URI-encoded.
}
try {
const url = new URL(value);
this.addPathTitleCandidates(candidates, url.pathname);
} catch {
this.addPathTitleCandidates(candidates, value);
}
}
private addPathTitleCandidates(candidates: Set<string>, path: string) {
const pathSegments = path.split("/").filter(Boolean);
const lastPathSegment = pathSegments[pathSegments.length - 1];
if (!lastPathSegment) {
return;
}
candidates.add(lastPathSegment);
try {
candidates.add(decodeURIComponent(lastPathSegment));
} catch {
// Keep the original segment if it is not URI-encoded.
}
}
2025-03-05 00:10:23 -08:00
}