polishing the edges

This commit is contained in:
cool.gitter.not.me.again.duh
2025-05-26 20:44:25 -06:00
parent 62d1e91a02
commit 59370367bd
12 changed files with 313 additions and 98 deletions

View File

@@ -6,24 +6,10 @@
/Test.py
/prgs/
/flask_server.log
/routes/__pycache__/
routes/utils/__pycache__/
test.sh
__pycache__/
routes/__pycache__/__init__.cpython-312.pyc
routes/__pycache__/credentials.cpython-312.pyc
routes/__pycache__/search.cpython-312.pyc
routes/utils/__pycache__/__init__.cpython-312.pyc
routes/utils/__pycache__/credentials.cpython-312.pyc
routes/utils/__pycache__/search.cpython-312.pyc
routes/utils/__pycache__/__init__.cpython-312.pyc
routes/utils/__pycache__/credentials.cpython-312.pyc
routes/utils/__pycache__/search.cpython-312.pyc
routes/utils/__pycache__/credentials.cpython-312.pyc
routes/utils/__pycache__/search.cpython-312.pyc
routes/utils/__pycache__/__init__.cpython-312.pyc
routes/utils/__pycache__/credentials.cpython-312.pyc
routes/utils/__pycache__/search.cpython-312.pyc
routes/__pycache__/*
routes/utils/__pycache__/*
search_test.py
config/main.json
.cache
@@ -31,4 +17,5 @@ config/state/queue_state.json
output.log
queue_state.json
search_demo.py
celery_worker.log
celery_worker.log
static/js/*

3
.gitignore vendored
View File

@@ -33,4 +33,5 @@ queue_state.json
search_demo.py
celery_worker.log
logs/spotizerr.log
/.venv
/.venv
static/js

View File

@@ -10,6 +10,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
gosu \
git \
ffmpeg \
nodejs \
npm \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
@@ -22,6 +24,14 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Install TypeScript globally
RUN npm install -g typescript
# Compile TypeScript
# tsc will use tsconfig.json from the current directory (/app)
# It will read from /app/src/js and output to /app/static/js
RUN tsc
# Create necessary directories with proper permissions
RUN mkdir -p downloads config creds logs && \
chmod 777 downloads config creds logs

View File

@@ -9,7 +9,7 @@ services:
- ./logs:/app/logs # <-- Volume for persistent logs
ports:
- 7171:7171
image: cooldockerizer93/spotizerr:dev
image: test
container_name: spotizerr-app
restart: unless-stopped
environment:

View File

@@ -1,12 +1,27 @@
import { downloadQueue } from './queue.js';
// Interfaces for validator data
interface SpotifyValidatorData {
username: string;
credentials?: string; // Credentials might be optional if only username is used as an identifier
}
interface SpotifySearchValidatorData {
client_id: string;
client_secret: string;
}
interface DeezerValidatorData {
arl: string;
}
const serviceConfig: Record<string, any> = {
spotify: {
fields: [
{ id: 'username', label: 'Username', type: 'text' },
{ id: 'credentials', label: 'Credentials', type: 'text' }
{ id: 'credentials', label: 'Credentials', type: 'text' } // Assuming this is password/token
],
validator: (data) => ({
validator: (data: SpotifyValidatorData) => ({ // Typed data
username: data.username,
credentials: data.credentials
}),
@@ -15,7 +30,7 @@ const serviceConfig: Record<string, any> = {
{ id: 'client_id', label: 'Client ID', type: 'text' },
{ id: 'client_secret', label: 'Client Secret', type: 'password' }
],
searchValidator: (data) => ({
searchValidator: (data: SpotifySearchValidatorData) => ({ // Typed data
client_id: data.client_id,
client_secret: data.client_secret
})
@@ -24,7 +39,7 @@ const serviceConfig: Record<string, any> = {
fields: [
{ id: 'arl', label: 'ARL', type: 'text' }
],
validator: (data) => ({
validator: (data: DeezerValidatorData) => ({ // Typed data
arl: data.arl
})
}

View File

@@ -1,6 +1,75 @@
// main.ts
import { downloadQueue } from './queue.js';
// Define interfaces for API data and search results
interface Image {
url: string;
height?: number;
width?: number;
}
interface Artist {
id?: string; // Artist ID might not always be present in search results for track artists
name: string;
external_urls?: { spotify?: string };
genres?: string[]; // For artist type results
}
interface Album {
id?: string; // Album ID might not always be present
name: string;
images?: Image[];
album_type?: string; // Used in startDownload
artists?: Artist[]; // Album can have artists too
total_tracks?: number;
release_date?: string;
external_urls?: { spotify?: string };
}
interface Track {
id: string;
name: string;
artists: Artist[];
album: Album;
duration_ms?: number;
explicit?: boolean;
external_urls: { spotify: string };
href?: string; // Some spotify responses use href
}
interface Playlist {
id: string;
name: string;
owner: { display_name?: string; id?: string };
images?: Image[];
tracks: { total: number }; // Simplified for search results
external_urls: { spotify: string };
href?: string; // Some spotify responses use href
explicit?: boolean; // Playlists themselves aren't explicit, but items can be
}
// Specific item types for search results
interface TrackResultItem extends Track {}
interface AlbumResultItem extends Album { id: string; images?: Image[]; explicit?: boolean; external_urls: { spotify: string }; href?: string; }
interface PlaylistResultItem extends Playlist {}
interface ArtistResultItem extends Artist { id: string; images?: Image[]; explicit?: boolean; external_urls: { spotify: string }; href?: string; followers?: { total: number }; }
// Union type for any search result item
type SearchResultItem = TrackResultItem | AlbumResultItem | PlaylistResultItem | ArtistResultItem;
// Interface for the API response structure
interface SearchResponse {
items: SearchResultItem[];
// Add other top-level properties from the search API if needed (e.g., total, limit, offset)
}
// Interface for the item passed to downloadQueue.download
interface DownloadQueueItem {
name: string;
artist?: string;
album?: { name: string; album_type?: string };
}
document.addEventListener('DOMContentLoaded', function() {
// DOM elements
const searchInput = document.getElementById('searchInput') as HTMLInputElement | null;
@@ -106,7 +175,7 @@ document.addEventListener('DOMContentLoaded', function() {
throw new Error('Network response was not ok');
}
const data = await response.json();
const data = await response.json() as SearchResponse; // Assert type for API response
// Hide loading indicator
showLoading(false);
@@ -164,7 +233,7 @@ document.addEventListener('DOMContentLoaded', function() {
/**
* Filters out items with null/undefined essential display parameters based on search type
*/
function filterValidItems(items: any[], type: string) {
function filterValidItems(items: SearchResultItem[], type: string): SearchResultItem[] {
if (!items) return [];
return items.filter(item => {
@@ -172,59 +241,59 @@ document.addEventListener('DOMContentLoaded', function() {
if (!item) return false;
// Skip explicit content if filter is enabled
if (downloadQueue.isExplicitFilterEnabled() && item.explicit === true) {
if (downloadQueue.isExplicitFilterEnabled() && ('explicit' in item && item.explicit === true)) {
return false;
}
// Check essential parameters based on search type
switch (type) {
case 'track':
// For tracks, we need name, artists, and album
const trackItem = item as TrackResultItem;
return (
item.name &&
item.artists &&
item.artists.length > 0 &&
item.artists[0] &&
item.artists[0].name &&
item.album &&
item.album.name &&
item.external_urls &&
item.external_urls.spotify
trackItem.name &&
trackItem.artists &&
trackItem.artists.length > 0 &&
trackItem.artists[0] &&
trackItem.artists[0].name &&
trackItem.album &&
trackItem.album.name &&
trackItem.external_urls &&
trackItem.external_urls.spotify
);
case 'album':
// For albums, we need name, artists, and cover image
const albumItem = item as AlbumResultItem;
return (
item.name &&
item.artists &&
item.artists.length > 0 &&
item.artists[0] &&
item.artists[0].name &&
item.external_urls &&
item.external_urls.spotify
albumItem.name &&
albumItem.artists &&
albumItem.artists.length > 0 &&
albumItem.artists[0] &&
albumItem.artists[0].name &&
albumItem.external_urls &&
albumItem.external_urls.spotify
);
case 'playlist':
// For playlists, we need name, owner, and tracks
const playlistItem = item as PlaylistResultItem;
return (
item.name &&
item.owner &&
item.owner.display_name &&
item.tracks &&
item.external_urls &&
item.external_urls.spotify
playlistItem.name &&
playlistItem.owner &&
playlistItem.owner.display_name &&
playlistItem.tracks &&
playlistItem.external_urls &&
playlistItem.external_urls.spotify
);
case 'artist':
// For artists, we need name
const artistItem = item as ArtistResultItem;
return (
item.name &&
item.external_urls &&
item.external_urls.spotify
artistItem.name &&
artistItem.external_urls &&
artistItem.external_urls.spotify
);
default:
// Default case - just check if the item exists
// Default case - just check if the item exists (already handled by `if (!item) return false;`)
return true;
}
});
@@ -233,7 +302,7 @@ document.addEventListener('DOMContentLoaded', function() {
/**
* Attaches download handlers to result cards
*/
function attachDownloadListeners(items: any[]) {
function attachDownloadListeners(items: SearchResultItem[]) {
document.querySelectorAll('.download-btn').forEach((btnElm) => {
const btn = btnElm as HTMLButtonElement;
btn.addEventListener('click', (e: Event) => {
@@ -262,10 +331,37 @@ document.addEventListener('DOMContentLoaded', function() {
}
// Prepare metadata for the download
const metadata = {
name: item.name || 'Unknown',
artist: item.artists ? item.artists[0]?.name : undefined
};
let metadata: DownloadQueueItem;
if (currentSearchType === 'track') {
const trackItem = item as TrackResultItem;
metadata = {
name: trackItem.name || 'Unknown',
artist: trackItem.artists ? trackItem.artists[0]?.name : undefined,
album: trackItem.album ? { name: trackItem.album.name, album_type: trackItem.album.album_type } : undefined
};
} else if (currentSearchType === 'album') {
const albumItem = item as AlbumResultItem;
metadata = {
name: albumItem.name || 'Unknown',
artist: albumItem.artists ? albumItem.artists[0]?.name : undefined,
album: { name: albumItem.name, album_type: albumItem.album_type}
};
} else if (currentSearchType === 'playlist') {
const playlistItem = item as PlaylistResultItem;
metadata = {
name: playlistItem.name || 'Unknown',
// artist for playlist is owner
artist: playlistItem.owner?.display_name
};
} else if (currentSearchType === 'artist') {
const artistItem = item as ArtistResultItem;
metadata = {
name: artistItem.name || 'Unknown',
artist: artistItem.name // For artist type, artist is the item name itself
};
} else {
metadata = { name: item.name || 'Unknown' }; // Fallback
}
// Disable the button and update text
btn.disabled = true;
@@ -278,7 +374,8 @@ document.addEventListener('DOMContentLoaded', function() {
}
// Start the download
startDownload(url, currentSearchType, metadata, item.album ? item.album.album_type : null)
startDownload(url, currentSearchType, metadata,
(item as AlbumResultItem).album_type || ((item as TrackResultItem).album ? (item as TrackResultItem).album.album_type : null))
.then(() => {
// For artists, show how many albums were queued
if (currentSearchType === 'artist') {
@@ -301,7 +398,7 @@ document.addEventListener('DOMContentLoaded', function() {
/**
* Starts the download process via API
*/
async function startDownload(url: string, type: string, item: any, albumType: string | null) {
async function startDownload(url: string, type: string, item: DownloadQueueItem, albumType: string | null | undefined) {
if (!url || !type) {
showError('Missing URL or type for download');
return;
@@ -385,7 +482,7 @@ document.addEventListener('DOMContentLoaded', function() {
/**
* Creates a result card element
*/
function createResultCard(item: any, type: string, index: number): HTMLDivElement {
function createResultCard(item: SearchResultItem, type: string, index: number): HTMLDivElement {
const cardElement = document.createElement('div');
cardElement.className = 'result-card';
@@ -394,10 +491,22 @@ document.addEventListener('DOMContentLoaded', function() {
// Get the appropriate image URL
let imageUrl = '/static/images/placeholder.jpg';
if (item.album && item.album.images && item.album.images.length > 0) {
imageUrl = item.album.images[0].url;
} else if (item.images && item.images.length > 0) {
imageUrl = item.images[0].url;
// Type guards to safely access images
if (type === 'album' || type === 'artist') {
const albumOrArtistItem = item as AlbumResultItem | ArtistResultItem;
if (albumOrArtistItem.images && albumOrArtistItem.images.length > 0) {
imageUrl = albumOrArtistItem.images[0].url;
}
} else if (type === 'track') {
const trackItem = item as TrackResultItem;
if (trackItem.album && trackItem.album.images && trackItem.album.images.length > 0) {
imageUrl = trackItem.album.images[0].url;
}
} else if (type === 'playlist') {
const playlistItem = item as PlaylistResultItem;
if (playlistItem.images && playlistItem.images.length > 0) {
imageUrl = playlistItem.images[0].url;
}
}
// Get the appropriate details based on type
@@ -406,20 +515,32 @@ document.addEventListener('DOMContentLoaded', function() {
switch (type) {
case 'track':
subtitle = item.artists ? item.artists.map(a => a.name).join(', ') : 'Unknown Artist';
details = item.album ? `<span>${item.album.name}</span><span class="duration">${msToMinutesSeconds(item.duration_ms)}</span>` : '';
{
const trackItem = item as TrackResultItem;
subtitle = trackItem.artists ? trackItem.artists.map((a: Artist) => a.name).join(', ') : 'Unknown Artist';
details = trackItem.album ? `<span>${trackItem.album.name}</span><span class="duration">${msToMinutesSeconds(trackItem.duration_ms)}</span>` : '';
}
break;
case 'album':
subtitle = item.artists ? item.artists.map(a => a.name).join(', ') : 'Unknown Artist';
details = `<span>${item.total_tracks || 0} tracks</span><span>${item.release_date ? new Date(item.release_date).getFullYear() : ''}</span>`;
{
const albumItem = item as AlbumResultItem;
subtitle = albumItem.artists ? albumItem.artists.map((a: Artist) => a.name).join(', ') : 'Unknown Artist';
details = `<span>${albumItem.total_tracks || 0} tracks</span><span>${albumItem.release_date ? new Date(albumItem.release_date).getFullYear() : ''}</span>`;
}
break;
case 'playlist':
subtitle = `By ${item.owner ? item.owner.display_name : 'Unknown'}`;
details = `<span>${item.tracks && item.tracks.total ? item.tracks.total : 0} tracks</span>`;
{
const playlistItem = item as PlaylistResultItem;
subtitle = `By ${playlistItem.owner ? playlistItem.owner.display_name : 'Unknown'}`;
details = `<span>${playlistItem.tracks && playlistItem.tracks.total ? playlistItem.tracks.total : 0} tracks</span>`;
}
break;
case 'artist':
subtitle = 'Artist';
details = item.genres ? `<span>${item.genres.slice(0, 2).join(', ')}</span>` : '';
{
const artistItem = item as ArtistResultItem;
subtitle = 'Artist';
details = artistItem.genres ? `<span>${artistItem.genres.slice(0, 2).join(', ')}</span>` : '';
}
break;
}

View File

@@ -1,6 +1,68 @@
// Import the downloadQueue singleton from your working queue.js implementation.
import { downloadQueue } from './queue.js';
// Define interfaces for API data
interface Image {
url: string;
height?: number;
width?: number;
}
interface Artist {
id: string;
name: string;
external_urls?: { spotify?: string };
}
interface Album {
id: string;
name: string;
images?: Image[];
external_urls?: { spotify?: string };
}
interface Track {
id: string;
name: string;
artists: Artist[];
album: Album;
duration_ms: number;
explicit: boolean;
external_urls?: { spotify?: string };
}
interface PlaylistItem {
track: Track | null;
// Add other playlist item properties like added_at, added_by if needed
}
interface Playlist {
id: string;
name: string;
description: string | null;
owner: {
display_name?: string;
id?: string;
};
images: Image[];
tracks: {
items: PlaylistItem[];
total: number;
};
followers?: {
total: number;
};
external_urls?: { spotify?: string };
}
interface DownloadQueueItem {
name: string;
artist?: string; // Can be a simple string for the queue
album?: { name: string }; // Match QueueItem's album structure
owner?: string; // For playlists, owner can be a string
// Add any other properties your item might have, compatible with QueueItem
}
document.addEventListener('DOMContentLoaded', () => {
// Parse playlist ID from URL
const pathSegments = window.location.pathname.split('/');
@@ -15,7 +77,7 @@ document.addEventListener('DOMContentLoaded', () => {
fetch(`/api/playlist/info?id=${encodeURIComponent(playlistId)}`)
.then(response => {
if (!response.ok) throw new Error('Network response was not ok');
return response.json();
return response.json() as Promise<Playlist>;
})
.then(data => renderPlaylist(data))
.catch(error => {
@@ -34,7 +96,7 @@ document.addEventListener('DOMContentLoaded', () => {
/**
* Renders playlist header and tracks.
*/
function renderPlaylist(playlist: any) {
function renderPlaylist(playlist: Playlist) {
// Hide loading and error messages
const loadingEl = document.getElementById('loading');
if (loadingEl) loadingEl.classList.add('hidden');
@@ -80,7 +142,7 @@ function renderPlaylist(playlist: any) {
// Check if any track in the playlist is explicit when filter is enabled
let hasExplicitTrack = false;
if (isExplicitFilterEnabled && playlist.tracks?.items) {
hasExplicitTrack = playlist.tracks.items.some(item => item?.track && item.track.explicit);
hasExplicitTrack = playlist.tracks.items.some((item: PlaylistItem) => item?.track && item.track.explicit);
}
// --- Add "Download Whole Playlist" Button ---
@@ -178,7 +240,7 @@ function renderPlaylist(playlist: any) {
tracksList.innerHTML = ''; // Clear any existing content
if (playlist.tracks?.items) {
playlist.tracks.items.forEach((item, index) => {
playlist.tracks.items.forEach((item: PlaylistItem, index: number) => {
if (!item || !item.track) return; // Skip null/undefined tracks
const track = item.track;
@@ -281,7 +343,10 @@ function attachDownloadListeners() {
const name = currentTarget.dataset.name || extractName(url) || 'Unknown';
// Remove the button immediately after click.
currentTarget.remove();
startDownload(url, type, { name }, ''); // Added empty string for albumType
// For individual track downloads, we might not have album/artist name readily here.
// The queue.ts download method should be robust enough or we might need to fetch more data.
// For now, pass what we have.
startDownload(url, type, { name }, ''); // Pass name, artist/album are optional in DownloadQueueItem
});
});
}
@@ -289,7 +354,7 @@ function attachDownloadListeners() {
/**
* Initiates the whole playlist download by calling the playlist endpoint.
*/
async function downloadWholePlaylist(playlist: any) {
async function downloadWholePlaylist(playlist: Playlist) {
if (!playlist) {
throw new Error('Invalid playlist data');
}
@@ -301,7 +366,11 @@ async function downloadWholePlaylist(playlist: any) {
try {
// Use the centralized downloadQueue.download method
await downloadQueue.download(url, 'playlist', { name: playlist.name || 'Unknown Playlist' });
await downloadQueue.download(url, 'playlist', {
name: playlist.name || 'Unknown Playlist',
owner: playlist.owner?.display_name // Pass owner as a string
// total_tracks can also be passed if QueueItem supports it directly
});
// Make the queue visible after queueing
downloadQueue.toggleVisibility(true);
} catch (error: any) {
@@ -315,15 +384,15 @@ async function downloadWholePlaylist(playlist: any) {
* adding a 20ms delay between each album download and updating the button
* with the progress (queued_albums/total_albums).
*/
async function downloadPlaylistAlbums(playlist: any) {
async function downloadPlaylistAlbums(playlist: Playlist) {
if (!playlist?.tracks?.items) {
showError('No tracks found in this playlist.');
return;
}
// Build a map of unique albums (using album ID as the key).
const albumMap = new Map();
playlist.tracks.items.forEach(item => {
const albumMap = new Map<string, Album>();
playlist.tracks.items.forEach((item: PlaylistItem) => {
if (!item?.track?.album) return;
const album = item.track.album;
@@ -359,7 +428,11 @@ async function downloadPlaylistAlbums(playlist: any) {
await downloadQueue.download(
albumUrl,
'album',
{ name: album.name || 'Unknown Album' }
{
name: album.name || 'Unknown Album',
// If artist information is available on album objects from playlist, pass it
// artist: album.artists?.[0]?.name
}
);
// Update button text with current progress.
@@ -387,7 +460,7 @@ async function downloadPlaylistAlbums(playlist: any) {
/**
* Starts the download process using the centralized download method from the queue.
*/
async function startDownload(url: string, type: string, item: any, albumType?: string) {
async function startDownload(url: string, type: string, item: DownloadQueueItem, albumType?: string) {
if (!url || !type) {
showError('Missing URL or type for download');
return;

View File

@@ -826,7 +826,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
const entries = Object.values(this.queueEntries);
// Sorting: errors/canceled first (group 0), ongoing next (group 1), queued last (group 2, sorted by position).
entries.sort((a, b) => {
entries.sort((a: QueueEntry, b: QueueEntry) => {
const getGroup = (entry: QueueEntry) => { // Add type
if (entry.lastStatus && (entry.lastStatus.status === "error" || entry.lastStatus.status === "cancel")) {
return 0;
@@ -881,7 +881,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
if (visibleItems.length === 0) {
// No items in container, append all visible entries
container.innerHTML = ''; // Clear any empty state
visibleEntries.forEach(entry => {
visibleEntries.forEach((entry: QueueEntry) => {
// We no longer automatically start monitoring here
// Monitoring is now explicitly started by the methods that create downloads
container.appendChild(entry.element);
@@ -890,7 +890,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
// Container already has items, update more efficiently
// Create a map of current DOM elements by queue ID
const existingElementMap = {};
const existingElementMap: { [key: string]: HTMLElement } = {};
visibleItems.forEach(el => {
const queueId = (el.querySelector('.cancel-btn') as HTMLElement | null)?.dataset.queueid; // Optional chaining
if (queueId) existingElementMap[queueId] = el as HTMLElement; // Cast to HTMLElement
@@ -900,7 +900,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
container.innerHTML = '';
// Add visible entries in correct order
visibleEntries.forEach(entry => {
visibleEntries.forEach((entry: QueueEntry) => {
// We no longer automatically start monitoring here
container.appendChild(entry.element);
@@ -931,7 +931,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
/* Checks if an entry is visible in the queue display. */
isEntryVisible(queueId: string): boolean { // Add return type
const entries = Object.values(this.queueEntries);
entries.sort((a, b) => {
entries.sort((a: QueueEntry, b: QueueEntry) => {
const getGroup = (entry: QueueEntry) => { // Add type
if (entry.lastStatus && (entry.lastStatus.status === "error" || entry.lastStatus.status === "cancel")) {
return 0;
@@ -954,7 +954,7 @@ createQueueItem(item: QueueItem, type: string, prgFile: string, queueId: string)
return a.lastUpdated - b.lastUpdated;
}
});
const index = entries.findIndex(e => e.uniqueId === queueId);
const index = entries.findIndex((e: QueueEntry) => e.uniqueId === queueId);
return index >= 0 && index < this.visibleCount;
}

View File

@@ -1,15 +1,23 @@
{
"compilerOptions": {
"target": "es2017", // Specify ECMAScript target version
"target": "ES2017", // Specify ECMAScript target version
"module": "ES2020", // Specify module code generation
"strict": true, // Enable all strict type-checking options
"noImplicitAny": false, // Allow implicit 'any' types
"esModuleInterop": true, // Enables emit interoperability between CommonJS and ES Modules
"skipLibCheck": true, // Skip type checking of declaration files
"forceConsistentCasingInFileNames": true // Disallow inconsistently-cased references to the same file.
"forceConsistentCasingInFileNames": true, // Disallow inconsistently-cased references to the same file.
"outDir": "./static/js",
"rootDir": "./src/js"
},
"include": [
"static/js/**/*.ts" // Specifies the TypeScript files to be included in compilation
"src/js/**/*.ts",
"src/js/album.ts",
"src/js/artist.ts",
"src/js/config.ts",
"src/js/main.ts",
"src/js/playlist.ts",
"src/js/queue.ts",
"src/js/track.ts"
],
"exclude": [
"node_modules" // Specifies an array of filenames or patterns that should be skipped when resolving include.