Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- 'use strict';
- const exec = require('child_process').exec;
- const libQ = require('kew');
- const path = require('path');
- const vConf = require('v-conf');
- const fs = require('fs-extra');
- const MultiSourceAPI = require('./multi-source-api');
- const FAVORITES_FILE = 'favorites.json';
- fs.ensureDirSync('/data/plugins/music_service/monochrome/temp/dash');
- const util = require('util');
- const execPromise = util.promisify(exec); // używamy istniejącego exec
- module.exports = ControllerMonochrome;
- function ControllerMonochrome(context) {
- this.context = context;
- this.commandRouter = this.context.coreCommand;
- this.logger = this.context.logger;
- this.config = null;
- this.configFile = null;
- this.api = null;
- this.currentTrack = null;
- this.lastPlayTime = null; // czas ostatniego odtworzenia
- this.currentState = 'stop'; // bieżący stan odtwarzacza
- }
- // ------------------------------------------------------------------
- // VOLUMIO LIFECYCLE
- // ------------------------------------------------------------------
- ControllerMonochrome.prototype.onVolumioStart = function (conf) {
- this.config = conf;
- return libQ.resolve();
- };
- ControllerMonochrome.prototype.onStart = function () {
- const self = this;
- const defer = libQ.defer();
- try {
- self.configFile = self.commandRouter.pluginManager.getConfigurationFile(self.context, 'config.json');
- console.log('[Monochrome] Config file path:', self.configFile);
- let config = {};
- if (fs.existsSync(self.configFile)) {
- try {
- config = fs.readJsonSync(self.configFile) || {};
- console.log('[Monochrome] Config loaded from file');
- } catch (e) {
- console.log('[Monochrome] Config file corrupted, creating default config');
- config = {};
- }
- }
- const defaultInstances = [
- 'https://api.monochrome.tf',
- 'https://arran.monochrome.tf',
- 'https://triton.squid.wtf'
- ];
- // Normalizacja + defaulty (underscore)
- config.instances = (Array.isArray(config.instances) && config.instances.length)
- ? config.instances.filter(i => typeof i === 'string' && i.startsWith('http'))
- : defaultInstances;
- config.qobuz_api_base = (typeof config.qobuz_api_base === 'string' && config.qobuz_api_base.startsWith('http'))
- ? config.qobuz_api_base
- : 'https://qobuz.squid.wtf/api';
- config.source = (['tidal', 'qobuz', 'auto'].includes(config.source)) ? config.source : 'tidal';
- config.quality = (typeof config.quality === 'string' && config.quality.trim() !== '')
- ? config.quality.trim()
- : 'LOSSLESS';
- config.search_limit = (typeof config.search_limit === 'number') ? config.search_limit : 20;
- config.timeout = (typeof config.timeout === 'number') ? config.timeout : 7000;
- config.album_sort = (['newest', 'oldest', 'title'].includes(config.album_sort)) ? config.album_sort : 'newest';
- config.enable_artist_search = !!config.enable_artist_search;
- config.enable_album_search = !!config.enable_album_search;
- config.enable_playlist_search = !!config.enable_playlist_search;
- // Spotify (opcjonalnie)
- config.spotify_client_id = typeof config.spotify_client_id === 'string' ? config.spotify_client_id : '';
- config.spotify_client_secret = typeof config.spotify_client_secret === 'string' ? config.spotify_client_secret : '';
- config.spotify_playlist_url = typeof config.spotify_playlist_url === 'string' ? config.spotify_playlist_url : '';
- fs.writeJsonSync(self.configFile, config, { spaces: 2 });
- console.log('[Monochrome] Config saved to file');
- self.configObject = config;
- self.config = new vConf();
- self.config.loadFile(self.configFile);
- const apiSettings = {
- getInstances: async () => config.instances || defaultInstances,
- getConf: async (key, defVal) => (Object.prototype.hasOwnProperty.call(config, key) ? config[key] : defVal)
- };
- self.api = new MultiSourceAPI(apiSettings);
- // Monitorowanie stanu MPD co xxxx minutę (tylko Monochrome)
- if (!self._mpdMonitorInterval) {
- self.logger.info('[Monochrome] Starting MPD monitor (10s)');
- // odpal raz od razu (nie czekaj 60s)
- try { self._checkMpdState(); } catch (e) {}
- self._mpdMonitorInterval = setInterval(() => {
- try { self._checkMpdState(); } catch (e) {}
- }, 10000);
- }
- self.addToBrowseSources();
- self.logger.info('[Monochrome] Started');
- // Wyczyść kolejkę Volumio podczas startu pluginu (np. po restarcie maliny)
- self.commandRouter.volumioClearQueue();
- self.logger.info('[Monochrome] Queue cleared on startup');
- // Sprawdzanie, czy odtwarzacz jest zatrzymany od 2h i czyszczenie kolejki
- self._idleQueueClearInterval = setInterval(() => {
- if (self.currentState === 'stop' && self.lastPlayTime && (Date.now() - self.lastPlayTime > 2 * 60 * 60 * 1000)) {
- self.logger.info('[Monochrome] Odtwarzacz zatrzymany od 2h – czyszczenie kolejki');
- self.commandRouter.volumioClearQueue(); // czyści całą kolejkę
- self.logger.info('[Monochrome] Kolejka została wyczyszczona');
- // Opcjonalnie: po wyczyszczeniu kolejki usuń też nieużywane pliki DASH
- self._cleanUnusedDashFiles(0);
- } else {
- // Logi pomocnicze – możesz je usunąć, gdy nie będą potrzebne
- if (self.currentState === 'pause') {
- self.logger.debug('[Monochrome] Pauza – nie czyszczę kolejki');
- }
- }
- }, 30 * 60 * 1000); // sprawdzanie co 30 min
- self._cleanUnusedDashFiles(0).catch(e => self.logger.error(e)); // Przy starcie usuń wszystkie nieużywane pliki (bez względu na wiek)
- self.lowSpaceInterval= setInterval(() => {
- self.cleanDashIfLowSpace().catch(e => self.logger.error(e?.message || String(e)));
- }, 5 * 60 * 1000);
- // Uruchom serwer proxy DASH
- const { execSync } = require('child_process');
- try {
- execSync('fuser -k 3002/tcp'); // zabija proces na porcie 3002
- } catch (e) {
- // ignoruj, jeśli port nie był zajęty
- }
- const { fork } = require('child_process');
- const proxyPath = path.join(__dirname, 'dash-proxy.js');
- self.dashProxy = fork(proxyPath);
- self.dashProxy.on('error', (err) => {
- self.logger.error('[Monochrome] Dash proxy error:', err);
- });
- self.dashProxy.on('exit', (code) => {
- self.logger.info(`[Monochrome] Dash proxy exited with code ${code}`);
- });
- self.logger.info('[Monochrome] Dash proxy started');
- // co 30 min
- self._dashCleanInterval = setInterval(() => {
- self._cleanUnusedDashFiles(60 * 60 * 1000).catch(e => self.logger.error(e));
- }, 30 * 60 * 1000);
- // Uruchom pierwsze sprawdzenie
- self._refreshExpiredPlaylists().catch(e => self.logger.error(e));
- // Ustaw interwał co 6 godzin
- self._refreshInterval = setInterval(() => {
- self._refreshExpiredPlaylists().catch(e => self.logger.error(e));
- }, 6 * 60 * 60 * 1000); // co 6 godzin
- self.addToBrowseSources();
- self.logger.info('[Monochrome] Started');
- defer.resolve();
- } catch (e) {
- self.logger.error('[Monochrome] onStart error: ' + (e && e.message ? e.message : e));
- defer.reject(e);
- }
- return defer.promise;
- };
- ControllerMonochrome.prototype._cleanUnusedDashFiles = function (maxAgeMs = 60 * 60 * 1000) {
- const self = this;
- const fs = require('fs-extra');
- const path = require('path');
- const tempDir = '/data/plugins/music_service/monochrome/temp/dash';
- // libQ/kew -> native Promise
- const toPromise = (p) =>
- new Promise((resolve, reject) => {
- try {
- if (p && typeof p.then === 'function') {
- p.then(resolve).fail ? p.then(resolve).fail(reject) : p.then(resolve, reject);
- } else {
- resolve(p);
- }
- } catch (e) {
- reject(e);
- }
- });
- return toPromise(self.commandRouter.volumioGetQueue())
- .then(async (queue) => {
- if (!queue || !Array.isArray(queue)) {
- self.logger.warn('[Monochrome] Nie można pobrać kolejki do czyszczenia DASH');
- return;
- }
- // Pliki używane w kolejce
- const usedFiles = new Set();
- for (const item of queue) {
- if (!item || !item.uri || typeof item.uri !== 'string') continue;
- if (item.uri.startsWith('file://')) {
- // "file:///data/..." -> "/data/..."
- let filePath = item.uri.replace(/^file:\/\//, '');
- // upewnij się, że nie ma podwójnych slashy na początku
- if (filePath.startsWith('/')) {
- // ok
- } else {
- filePath = '/' + filePath;
- }
- usedFiles.add(path.normalize(filePath));
- }
- }
- let files;
- try {
- files = await fs.readdir(tempDir);
- } catch (e) {
- self.logger.warn('[Monochrome] Brak katalogu DASH lub brak dostępu: ' + tempDir);
- return;
- }
- const now = Date.now();
- let removed = 0;
- let kept = 0;
- for (const file of files) {
- const filePath = path.join(tempDir, file);
- try {
- const stats = await fs.stat(filePath);
- const isUsed = usedFiles.has(path.normalize(filePath));
- const isOldEnough = maxAgeMs <= 0 ? true : ((now - stats.mtimeMs) > maxAgeMs);
- // usuń jeśli nieużywany + stary (albo maxAgeMs=0)
- if (!isUsed && isOldEnough) {
- await fs.unlink(filePath);
- removed++;
- } else {
- kept++;
- }
- } catch (e) {
- self.logger.error('[Monochrome] Błąd przy sprawdzaniu/usuwaniu ' + file + ': ' + (e?.message || String(e)));
- }
- }
- self.logger.info(`[Monochrome] DASH clean done: removed=${removed}, kept=${kept}, maxAgeMs=${maxAgeMs}`);
- })
- .catch((e) => {
- self.logger.error('[Monochrome] Błąd w _cleanUnusedDashFiles: ' + (e?.message || String(e)));
- });
- };
- ControllerMonochrome.prototype.onStop = function () {
- const self = this;
- if (self.dashProxy) {
- self.dashProxy.kill();
- self.dashProxy = null;
- }
- if (self._mpdMonitorInterval) {
- clearInterval(self._mpdMonitorInterval);
- self._mpdMonitorInterval = null;
- self.logger.info('[Monochrome] MPD monitor stopped');
- }
- if (this.lowSpaceInterval) {
- clearInterval(this.lowSpaceInterval);
- }
- if (this._idleQueueClearInterval) {
- clearInterval(this._idleQueueClearInterval);
- }
- if (this._dashCleanInterval) { // ‹ DODAJ TO
- clearInterval(this._dashCleanInterval);
- }
- if (this._refreshInterval) {
- clearInterval(this._refreshInterval);
- }
- try {
- self.commandRouter.volumioRemoveToBrowseSources('Monochrome');
- } catch (e) {}
- // Wyczyść kolejkę podczas wyłączania wtyczki
- try {
- self.commandRouter.volumioClearQueue();
- self.logger.info('[Monochrome] Queue cleared on stop');
- } catch (e) {
- self.logger.warn('[Monochrome] Error clearing queue on stop: ' + e.message);
- }
- self.logger.info('[Monochrome] Stopped');
- return libQ.resolve();
- };
- ControllerMonochrome.prototype.clearAddPlayTrack = function (track) {
- const self = this;
- const defer = libQ.defer();
- self.logger.info('[Monochrome] clearAddPlayTrack: ' + (track && track.uri));
- if (!track || !track.uri) {
- defer.reject(new Error('Invalid track'));
- return defer.promise;
- }
- // Jeśli URI jest bezpośrednim URL-em (rehydratacja), NIE używaj mpc.
- if (track.uri.startsWith('http://') || track.uri.startsWith('https://')) {
- const item = {
- service: 'mpd',
- type: 'song',
- uri: track.uri,
- title: track.title || 'Monochrome Stream',
- name: track.name || track.title || 'Monochrome Stream',
- artist: track.artist || '',
- album: track.album || '',
- albumart: track.albumart,
- duration: track.duration || 0
- };
- self.commandRouter.volumioClearAddPlayTrack(item)
- .then(() => defer.resolve())
- .catch((e) => {
- self.logger.error('[Monochrome] clearAddPlayTrack (http) error: ' + (e && e.message ? e.message : e));
- defer.reject(e);
- });
- return defer.promise;
- }
- // W przeciwnym razie: rehydratacja przez explodeUri i oddanie do Volumio
- self.explodeUri(track.uri)
- .then((tracks) => {
- if (!tracks || !Array.isArray(tracks) || tracks.length === 0) {
- throw new Error('No tracks returned from explodeUri');
- }
- return self.commandRouter.volumioClearAddPlayTrack(tracks[0]);
- })
- .then(() => defer.resolve())
- .catch((e) => {
- self.logger.error('[Monochrome] clearAddPlayTrack error: ' + (e && e.message ? e.message : e));
- defer.reject(e);
- });
- return defer.promise;
- };
- // ------------------------------------------------------------------
- // MPC – bezpośrednie sterowanie MPD
- // ------------------------------------------------------------------
- ControllerMonochrome.prototype._mpc = function (cmd) {
- const self = this;
- const defer = libQ.defer();
- exec(`mpc ${cmd}`, (err, stdout, stderr) => {
- if (err) {
- self.logger.error('[Monochrome] mpc error (' + cmd + '): ' + (stderr || err.message));
- return defer.reject(err);
- }
- defer.resolve((stdout || '').trim());
- });
- return defer.promise;
- };
- ControllerMonochrome.prototype.playViaMpcUrl = function (url, options) {
- const self = this;
- const defer = libQ.defer();
- const safeUrl = String(url).replace(/'/g, "'\\''");
- const enqueueOnly = options && options.enqueueOnly;
- let chain = libQ.resolve();
- if (!enqueueOnly) {
- chain = chain
- .then(() => self._mpc('clear'))
- .then(() => self._mpc(`add '${safeUrl}'`))
- .then(() => self._mpc('play'));
- } else {
- chain = chain.then(() => self._mpc(`add '${safeUrl}'`));
- }
- chain
- .then(() => {
- setTimeout(() => {
- try { self.commandRouter.executeOnPlugin('music_service', 'mpd', 'getState', ''); } catch (e) {}
- }, 250);
- defer.resolve();
- })
- .fail((e) => {
- self.logger.error('[Monochrome] playViaMpcUrl error: ' + (e && e.message ? e.message : e));
- defer.reject(e);
- });
- return defer.promise;
- };
- // ------------------------------------------------------------------
- // BROWSING & SEARCH UI
- // ------------------------------------------------------------------
- ControllerMonochrome.prototype.addToBrowseSources = function () {
- const data = {
- name: 'Monochrome',
- uri: 'monochrome',
- plugin_type: 'music_service',
- plugin_name: 'monochrome',
- albumart: '/albumart?sourceicon=music_service/monochrome/assets/icon.png',
- };
- this.commandRouter.volumioAddToBrowseSources(data);
- };
- ControllerMonochrome.prototype.handleBrowseUri = function (curUri) {
- const self = this;
- const defer = libQ.defer();
- console.log('[Monochrome] handleBrowseUri: ' + curUri);
- // Root
- if (curUri === 'monochrome') {
- defer.resolve({
- navigation: {
- lists: [{
- availableListViews: ['list'],
- type: 'title',
- title: 'Monochrome',
- items: [
- {
- service: 'monochrome',
- type: 'streaming-category',
- title: 'Search',
- icon: 'fa fa-search',
- uri: 'monochrome/search'
- },
- {
- service: 'monochrome',
- type: 'streaming-category',
- title: 'Favorite Playlists',
- icon: 'fa fa-heart',
- uri: 'monochrome/favorites/playlists'
- }
- ]
- }]
- }
- });
- return defer.promise;
- }
- if (curUri.startsWith('monochrome/favorites/toggle/playlist/')) {
- const pid = decodeURIComponent(curUri.substring('monochrome/favorites/toggle/playlist/'.length));
- const isSpotify = pid.startsWith('spotify:');
- if (isSpotify) {
- const favData = self.loadFavoritesData();
- const playlistId = pid.replace('spotify:', '');
- const imp = favData.spotifyImported && favData.spotifyImported[playlistId];
- if (!imp) {
- self.commandRouter.pushToastMessage('error', 'Monochrome', 'Playlist not found');
- defer.resolve({ navigation: { lists: [] } });
- return defer.promise;
- }
- const result = self.toggleFavoritePlaylist({
- id: pid,
- title: imp.title,
- uri: `monochrome_spotifypl:${playlistId}`,
- cover: imp.cover,
- type: 'spotify'
- });
- self.commandRouter.pushToastMessage(
- 'success',
- 'Monochrome',
- result.added ? 'Added to favorites' : 'Removed from favorites'
- );
- if (result.added) {
- // Playlista dodana – odśwież widok playlisty
- return self.handleBrowseUri(`monochrome_spotifypl:${playlistId}`)
- .then(response => defer.resolve(response))
- .catch(e => defer.reject(e));
- } else {
- // Playlista usunięta – wróć do widoku głównego (bez wywoływania handleBrowseUri)
- defer.resolve({
- navigation: {
- lists: [{
- availableListViews: ['list'],
- type: 'title',
- title: 'Monochrome',
- items: [
- {
- service: 'monochrome',
- type: 'streaming-category',
- title: 'Search',
- icon: 'fa fa-search',
- uri: 'monochrome/search'
- },
- {
- service: 'monochrome',
- type: 'streaming-category',
- title: 'Favorite Playlists',
- icon: 'fa fa-heart',
- uri: 'monochrome/favorites/playlists'
- }
- ]
- }]
- }
- });
- return defer.promise;
- }
- }else {
- // Playlista Tidal – pobierz z API
- self.api.getPlaylist(pid)
- .then((res) => {
- const playlist = res.playlist || {};
- const result = self.toggleFavoritePlaylist({
- id: pid,
- title: playlist.title || playlist.name || 'Playlist',
- uri: `monochrome/playlist/${pid}`,
- cover: playlist.cover || null,
- type: 'tidal'
- });
- self.commandRouter.pushToastMessage(
- 'success',
- 'Monochrome',
- result.added ? 'Added to favorites' : 'Removed from favorites'
- );
- return self.handleBrowseUri(`monochrome/playlist/${encodeURIComponent(pid)}`);
- })
- .then(response => defer.resolve(response))
- .catch(e => {
- self.logger.error('[Monochrome] toggle favorite error: ' + e.message);
- defer.reject(e);
- });
- }
- return defer.promise;
- }
- if (curUri.startsWith('monochrome_spotifypl:')) {
- const playlistId = curUri.substring('monochrome_spotifypl:'.length);
- const fav = self.loadFavoritesData();
- const imp = fav.spotifyImported && fav.spotifyImported[playlistId];
- if (!imp) {
- defer.resolve({ navigation: { lists: [] } });
- return defer.promise;
- }
- const pid = `spotify:${playlistId}`;
- const isFav = self.isFavoritePlaylist(pid);
- const actionItem = {
- service: 'monochrome',
- type: 'folder',
- title: isFav ? 'Remove from favorites' : 'Add to favorites',
- icon: isFav ? 'fa fa-heart' : 'fa fa-heart-o',
- albumart: '/albumart?sourceicon=music_service/monochrome/icon.png',
- uri: `monochrome/favorites/toggle/playlist/${encodeURIComponent(pid)}`,
- // Dodajemy typ, aby przy zapisie wiedzieć, że to Spotify
- _type: 'spotify'
- };
- const autoRefreshItem = {
- service: 'monochrome',
- type: 'folder',
- title: imp.autoRefresh ? 'Disable auto-refresh (every 2 days)' : 'Enable auto-refresh (every 2 days)',
- icon: imp.autoRefresh ? 'fa fa-toggle-on' : 'fa fa-toggle-off',
- albumart: '/albumart?sourceicon=music_service/monochrome/icon.png',
- uri: `monochrome/spotify/toggleAutoRefresh/${playlistId}`
- };
- const items = (imp.tracks || [])
- .filter(x => x && x.mapped && x.mapped.id)
- .map(x => ({
- service: 'monochrome',
- type: 'song',
- title: x.mapped.title || x.spotify.title || 'Unknown',
- artist: x.mapped.artist || (x.spotify.artists || []).join(', '),
- album: x.mapped.album || x.spotify.album || '',
- albumart: (() => {
- const coverId = x.mapped.cover || x.spotify.cover || imp.cover;
- return self.api.getCoverUrl ? self.api.getCoverUrl(coverId, 320) : coverId;
- })(),
- uri: 'monochrome://play/' + String(x.mapped.id),
- icon: 'fa fa-music'
- }));
- const playlistCoverUrl = imp.cover ? (self.api.getCoverUrl ? self.api.getCoverUrl(imp.cover, 320) : imp.cover) : null;
- defer.resolve({
- navigation: {
- prev: { uri: 'monochrome' },
- info: {
- uri: curUri,
- service: 'monochrome',
- albumart: playlistCoverUrl,
- title: imp.title || 'Imported Playlist',
- type: 'playlist',
- trackCount: items.length
- },
- lists: [
- {
- availableListViews: ['list'],
- title: imp.title || 'Imported Playlist',
- items: items
- },
- {
- availableListViews: ['list'],
- type: 'title',
- title: 'Options',
- items: [actionItem, autoRefreshItem] // teraz oba są folderami
- }
- ]
- }
- });
- return defer.promise;
- }
- if (curUri.startsWith('monochrome/spotify/toggleAutoRefresh/')) {
- const playlistId = decodeURIComponent(curUri.substring('monochrome/spotify/toggleAutoRefresh/'.length));
- const data = self.loadFavoritesData();
- const playlistEntry = data.playlists.find(p => String(p.id) === `spotify:${playlistId}`);
- if (playlistEntry) {
- playlistEntry.autoRefresh = !playlistEntry.autoRefresh;
- if (data.spotifyImported && data.spotifyImported[playlistId]) {
- data.spotifyImported[playlistId].autoRefresh = playlistEntry.autoRefresh;
- }
- self.saveFavoritesData(data);
- }
- // Pobierz świeże dane
- const updatedData = self.loadFavoritesData();
- const imp = updatedData.spotifyImported && updatedData.spotifyImported[playlistId];
- if (!imp) {
- defer.resolve({ navigation: { lists: [] } });
- return defer.promise;
- }
- const pid = `spotify:${playlistId}`;
- const isFav = self.isFavoritePlaylist(pid);
- const autoRefresh = imp.autoRefresh || false;
- const actionItem = {
- service: 'monochrome',
- type: 'folder',
- title: isFav ? 'Remove from favorites' : 'Add to favorites',
- icon: isFav ? 'fa fa-heart' : 'fa fa-heart-o',
- albumart: '/albumart?sourceicon=music_service/monochrome/icon.png',
- uri: `monochrome/favorites/toggle/playlist/${encodeURIComponent(pid)}`,
- _type: 'spotify'
- };
- const autoRefreshItem = {
- service: 'monochrome',
- type: 'folder',
- title: autoRefresh ? 'Disable auto-refresh' : 'Enable auto-refresh',
- icon: autoRefresh ? 'fa fa-toggle-on' : 'fa fa-toggle-off',
- albumart: '/albumart?sourceicon=music_service/monochrome/icon.png',
- uri: `monochrome/spotify/toggleAutoRefresh/${playlistId}`
- };
- const items = (imp.tracks || [])
- .filter(x => x && x.mapped && x.mapped.id)
- .map(x => ({
- service: 'monochrome',
- type: 'song',
- title: x.mapped.title || x.spotify.title || 'Unknown',
- artist: x.mapped.artist || (x.spotify.artists || []).join(', '),
- album: x.mapped.album || x.spotify.album || '',
- albumart: (() => {
- const coverId = x.mapped.cover || x.spotify.cover || imp.cover;
- return self.api.getCoverUrl ? self.api.getCoverUrl(coverId, 320) : coverId;
- })(),
- uri: 'monochrome://play/' + String(x.mapped.id),
- icon: 'fa fa-music'
- }));
- const playlistCoverUrl = imp.cover ? (self.api.getCoverUrl ? self.api.getCoverUrl(imp.cover, 320) : imp.cover) : null;
- defer.resolve({
- navigation: {
- prev: { uri: 'monochrome' },
- info: {
- uri: `monochrome_spotifypl:${playlistId}`,
- service: 'monochrome',
- albumart: playlistCoverUrl,
- title: imp.title || 'Imported Playlist',
- type: 'playlist',
- trackCount: items.length
- },
- lists: [
- {
- availableListViews: ['list'],
- title: imp.title || 'Imported Playlist',
- items: items
- },
- {
- availableListViews: ['list'],
- type: 'title',
- title: 'Options',
- items: [actionItem, autoRefreshItem]
- }
- ]
- }
- });
- return defer.promise;
- }
- // Search home
- if (curUri === 'monochrome/search') {
- defer.resolve({
- navigation: {
- prev: { uri: 'monochrome' },
- lists: [{
- availableListViews: ['list'],
- type: 'title',
- title: 'Search',
- items: [
- {
- service: 'monochrome',
- type: 'input',
- title: 'Tracks',
- icon: 'fa fa-music',
- placeholder: 'Enter track name...',
- uri: 'monochrome/search/tracks/'
- },
- {
- service: 'monochrome',
- type: 'input',
- title: 'Albums',
- icon: 'fa fa-folder',
- placeholder: 'Enter album name...',
- uri: 'monochrome/search/albums/'
- },
- {
- service: 'monochrome',
- type: 'input',
- title: 'Artists',
- icon: 'fa fa-user',
- placeholder: 'Enter artist name...',
- uri: 'monochrome/search/artists/'
- },
- {
- service: 'monochrome',
- type: 'input',
- title: 'Playlists',
- icon: 'fa fa-list',
- placeholder: 'Enter playlist name...',
- uri: 'monochrome/search/playlists/'
- }
- ]
- }]
- }
- });
- return defer.promise;
- }
- // Tracks search results
- if (curUri.startsWith('monochrome/search/tracks/')) {
- const q = decodeURIComponent(curUri.substring('monochrome/search/tracks/'.length));
- self.searchTracks({ value: q })
- .then((lists) => {
- defer.resolve({ navigation: { prev: { uri: 'monochrome/search' }, lists } });
- })
- .catch((e) => {
- self.logger.error('[Monochrome] tracks browse error: ' + e.message);
- defer.resolve({ navigation: { lists: [] } });
- });
- return defer.promise;
- }
- // Albums search results
- if (curUri.startsWith('monochrome/search/albums/')) {
- if (!self.config.get('enable_album_search')) {
- defer.resolve({ navigation: { prev: { uri: 'monochrome/search' }, lists: [] } });
- return defer.promise;
- }
- const q = decodeURIComponent(curUri.substring('monochrome/search/albums/'.length));
- self.searchAlbums({ value: q })
- .then((lists) => {
- defer.resolve({ navigation: { prev: { uri: 'monochrome/search' }, lists } });
- })
- .catch((e) => {
- self.logger.error('[Monochrome] albums browse error: ' + e.message);
- defer.resolve({ navigation: { lists: [] } });
- });
- return defer.promise;
- }
- // Artist root
- if (curUri.startsWith('monochrome/artist/') &&
- !curUri.endsWith('/toptracks') &&
- !curUri.endsWith('/albums') &&
- !curUri.endsWith('/eps')) {
- const artistId = decodeURIComponent(curUri.substring('monochrome/artist/'.length));
- // Pobierz limit z konfiguracji (lub ustaw domyślny 20)
- let limit = self.configObject?.search_limit;
- if (typeof limit !== 'number' || limit < 1) {
- limit = parseInt(self.config.get('search_limit'), 10);
- }
- if (isNaN(limit) || limit < 1) limit = 20;
- const limitTracks = limit; // możesz zmienić, jeśli chcesz inny limit dla utworów
- const limitAlbums = limit; // analogicznie
- const sortMode = self.configObject?.album_sort || self.config.get('album_sort') || 'newest';
- Promise.resolve()
- .then(() => self.api.getArtistMetadata ? self.api.getArtistMetadata(artistId) : null)
- .then((data) => {
- const artist = (data && data.artist) ? data.artist : { name: `Artist ${artistId}` };
- const artistName = artist.name || `Artist ${artistId}`;
- const picture = artist.picture || artist.image || null;
- // Równoległe pobieranie utworów i albumów
- return Promise.all([
- self.api.searchTracks(artistName, { limit: limitTracks * 2 }), // pobieramy więcej, bo będziemy filtrować
- self.api.searchAlbums(artistName, { limit: limitAlbums * 2 })
- ]).then(([tracksRes, albumsRes]) => {
- // Filtrujemy utwory – tylko te, które mają artystę pasującego do nazwy
- const allTracks = (tracksRes.items || []).filter(t => {
- const tArtist = (t.artist && t.artist.name) ? t.artist.name.toLowerCase() : '';
- return tArtist.includes(artistName.toLowerCase());
- });
- const tracks = allTracks.slice(0, limitTracks).map(t => ({
- service: 'monochrome',
- type: 'song',
- title: t.title || 'Unknown Track',
- artist: (t.artist && t.artist.name) ? t.artist.name : '',
- album: (t.album && t.album.title) ? t.album.title : '',
- albumart: self.api.getCoverUrl ? self.api.getCoverUrl(t.cover, '320') : undefined,
- uri: 'monochrome://play/' + t.id,
- duration: t.duration || 0
- }));
- // Filtrujemy albumy – tylko te, których artysta pasuje
- let allAlbums = (albumsRes.items || []).filter(a => {
- const aArtist = (a.artist && a.artist.name) ? a.artist.name.toLowerCase() : '';
- return aArtist.includes(artistName.toLowerCase());
- });
- // Sortowanie albumów
- if (allAlbums.length > 0) {
- allAlbums = allAlbums.map(item => {
- let timestamp = 0;
- if (item.releaseDate) {
- const d = new Date(item.releaseDate);
- timestamp = d.getTime() || 0;
- }
- return { ...item, _timestamp: timestamp };
- });
- if (sortMode === 'newest') {
- allAlbums.sort((a, b) => b._timestamp - a._timestamp);
- } else if (sortMode === 'oldest') {
- allAlbums.sort((a, b) => a._timestamp - b._timestamp);
- } else if (sortMode === 'title') {
- allAlbums.sort((a, b) => {
- const titleA = (a.title || '').toLowerCase();
- const titleB = (b.title || '').toLowerCase();
- return titleA.localeCompare(titleB);
- });
- }
- }
- // Mapowanie albumów z rokiem
- const albums = allAlbums.slice(0, limitAlbums).map(a => {
- let title = a.title || 'Unknown Album';
- if (a.releaseDate) {
- const year = new Date(a.releaseDate).getFullYear();
- if (!isNaN(year)) title += ` (${year})`;
- }
- return {
- service: 'monochrome',
- type: 'folder',
- title: title,
- artist: (a.artist && a.artist.name) ? a.artist.name : '',
- albumart: self.api.getCoverUrl ? self.api.getCoverUrl(a.cover, '320') : undefined,
- uri: a.id ? ('monochrome/album/' + a.id) : 'monochrome',
- };
- });
- // Tworzymy listy
- const lists = [];
- if (tracks.length > 0) {
- lists.push({
- availableListViews: ['list'],
- type: 'title',
- title: 'Popular tracks',
- items: tracks
- });
- }
- if (albums.length > 0) {
- lists.push({
- availableListViews: ['list', 'grid'],
- type: 'title',
- title: 'Albums',
- items: albums
- });
- }
- defer.resolve({
- navigation: {
- prev: { uri: 'monochrome/search' },
- info: {
- uri: curUri,
- service: 'monochrome',
- albumart: self.api.getCoverUrl ? self.api.getCoverUrl(picture, '320') : undefined,
- title: artistName,
- type: 'artist'
- },
- lists: lists
- }
- });
- });
- })
- .catch((e) => {
- self.logger.error('[Monochrome] artist root error: ' + e.message);
- defer.resolve({ navigation: { lists: [] } });
- });
- return defer.promise;
- }
- if (curUri === 'monochrome/favorites/playlists') {
- const favs = self.loadFavoritePlaylists();
- const items = favs.map(p => {
- const isSpotify = p.id.startsWith('spotify:') || p.type === 'spotify';
- let uri;
- if (isSpotify) {
- const playlistId = p.id.replace('spotify:', '');
- uri = `monochrome_spotifypl:${playlistId}`;
- } else {
- uri = `monochrome/playlist/${p.id}`;
- }
- return {
- service: 'monochrome',
- type: 'folder',
- title: p.title || 'Playlist',
- albumart: p.cover ? (self.api.getCoverUrl ? self.api.getCoverUrl(p.cover, '320') : p.cover) : '/albumart?sourceicon=music_service/monochrome/icon.png',
- icon: 'fa fa-list',
- uri: uri
- };
- });
- defer.resolve({
- navigation: {
- prev: { uri: 'monochrome' },
- lists: [{
- availableListViews: ['list'],
- type: 'title',
- title: 'Favorite Playlists',
- items: items
- }]
- }
- });
- return defer.promise;
- }
- // Playlists search results
- // Playlists search results
- if (curUri.startsWith('monochrome/search/playlists/')) {
- // Flaga z config.json: enable_playlist_search
- const enabled =
- (self.configObject && typeof self.configObject.enable_playlist_search !== 'undefined')
- ? !!self.configObject.enable_playlist_search
- : !!self.config.get('enable_playlist_search');
- if (!enabled) {
- defer.resolve({ navigation: { prev: { uri: 'monochrome/search' }, lists: [] } });
- return defer.promise;
- }
- const q = decodeURIComponent(curUri.substring('monochrome/search/playlists/'.length));
- self.searchPlaylists({ value: q })
- .then((lists) => {
- defer.resolve({ navigation: { prev: { uri: 'monochrome/search' }, lists } });
- })
- .catch((e) => {
- self.logger.error('[Monochrome] playlists browse error: ' + (e && e.message ? e.message : e));
- defer.resolve({ navigation: { prev: { uri: 'monochrome/search' }, lists: [] } });
- });
- return defer.promise;
- }
- // Album view
- if (curUri.startsWith('monochrome/album/')) {
- const albumId = decodeURIComponent(curUri.substring('monochrome/album/'.length));
- console.log('[Monochrome] Opening album:', albumId);
- self.api.getAlbum(albumId)
- .then((res) => {
- const album = res.album || {};
- // Sortowanie utworów po numerze płyty i ścieżki
- const tracks = (res.tracks || [])
- .filter(Boolean)
- .sort((a, b) => {
- if (a.volumeNumber !== b.volumeNumber) return (a.volumeNumber || 1) - (b.volumeNumber || 1);
- return (a.trackNumber || 0) - (b.trackNumber || 0);
- })
- .map(t => ({
- service: 'monochrome',
- type: 'song',
- title: t.title || 'Unknown Track',
- artist: (t.artist && t.artist.name) ? t.artist.name : '',
- album: album.title || '',
- albumart: self.api.getCoverUrl ? self.api.getCoverUrl(album.cover || t.cover, '320') : undefined,
- uri: 'monochrome://play/' + (t.id || t.trackId || t.itemId),
- icon: 'fa fa-music',
- duration: t.duration || 0
- }));
- // Opcjonalnie dodaj info, ale bez type: 'album' (lub ustaw type: 'folder')
- defer.resolve({
- navigation: {
- prev: { uri: 'monochrome/search' },
- info: {
- uri: `monochrome/album/${encodeURIComponent(albumId)}`, // zamiast curUri też OK, ale to jest jednoznaczne
- service: 'monochrome',
- albumart: self.api.getCoverUrl ? self.api.getCoverUrl(album.cover, '320') : undefined,
- title: album.title || 'Unknown Album',
- artist: (album.artist && album.artist.name) ? album.artist.name : '',
- type: 'album', //album
- year: album.releaseDate ? new Date(album.releaseDate).getFullYear() : undefined,
- trackCount: tracks.length
- },
- lists: [{
- availableListViews: ['list'],
- type: 'title',
- title: album.title || 'Album',
- items: tracks
- }]
- }
- });
- })
- .catch((e) => {
- self.logger.error('[Monochrome] album browse error: ' + e.message);
- defer.resolve({ navigation: { lists: [] } });
- });
- return defer.promise;
- }
- // Playlist view
- if (curUri.startsWith('monochrome/playlist/')) {
- const playlistId = decodeURIComponent(curUri.substring('monochrome/playlist/'.length));
- self.api.getPlaylist(playlistId)
- .then((res) => {
- const playlist = res.playlist || {};
- const pid = String(playlist.id || playlistId);
- const isFav = self.isFavoritePlaylist(pid);
- const actionItem = {
- service: 'monochrome',
- type: 'folder',
- title: isFav ? 'Remove from favorites' : 'Add to favorites',
- icon: isFav ? 'fa fa-heart' : 'fa fa-heart-o',
- albumart: '/albumart?sourceicon=music_service/monochrome/icon.png',
- uri: `monochrome/favorites/toggle/playlist/${encodeURIComponent(pid)}`
- };
- const tracks = (res.tracks || [])
- .filter(Boolean)
- .map(t => {
- const id = (t && (t.id || t.trackId || t.itemId)) ? String(t.id || t.trackId || t.itemId) : null;
- return {
- service: 'monochrome',
- type: 'song',
- title: (t && t.title) ? t.title : 'Unknown Track',
- artist: (t && t.artist && t.artist.name) ? t.artist.name : '',
- album: (t && t.album && t.album.title) ? t.album.title : '',
- albumart: self.api.getCoverUrl ? self.api.getCoverUrl(t.cover, '320') : undefined,
- // format docelowy:
- uri: id ? ('monochrome://play/' + encodeURIComponent(id)) : 'monochrome',
- icon: 'fa fa-music',
- duration: (t && t.duration) ? t.duration : 0
- };
- })
- // usuń rekordy bez poprawnego id (żeby nie było "monochrome" jako track)
- .filter(it => it.uri && it.uri.startsWith('monochrome://play/'));
- const coverUrl = self.api.getCoverUrl ? self.api.getCoverUrl(playlist.cover, '320') : undefined;
- const playlistTitle = playlist.title || playlist.name || 'Playlist';
- defer.resolve({
- navigation: {
- prev: { uri: 'monochrome/search' },
- info: {
- uri: curUri,
- service: 'monochrome',
- albumart: coverUrl,
- title: playlistTitle,
- type: 'playlist',
- trackCount: tracks.length
- },
- lists: [
- {
- availableListViews: ['list'],
- type: 'title',
- title: 'Tracks',
- items: tracks
- },
- {
- availableListViews: ['list'],
- type: 'title',
- title: 'Options',
- items: [actionItem]
- },
- ]
- }
- });
- })
- .catch((e) => {
- self.logger.error('[Monochrome] playlist browse error: ' + e.message);
- defer.resolve({ navigation: { lists: [] } });
- });
- return defer.promise;
- }
- // Artist root
- if (curUri.startsWith('monochrome/artist/') &&
- !curUri.endsWith('/toptracks') &&
- !curUri.endsWith('/albums') &&
- !curUri.endsWith('/eps')) {
- const artistId = decodeURIComponent(curUri.substring('monochrome/artist/'.length));
- Promise.resolve()
- .then(() => self.api.getArtistMetadata ? self.api.getArtistMetadata(artistId) : null)
- .then((data) => {
- const artist = (data && data.artist) ? data.artist : { name: `Artist ${artistId}` };
- const name = artist.name || `Artist ${artistId}`;
- defer.resolve({
- navigation: {
- prev: { uri: 'monochrome/search' },
- lists: [{
- availableListViews: ['list'],
- type: 'title',
- title: name,
- items: [
- { service: 'monochrome', type: 'streaming-category', title: 'Popular tracks', icon: 'fa fa-fire', uri: `monochrome/artist/${encodeURIComponent(artistId)}/toptracks` },
- { service: 'monochrome', type: 'streaming-category', title: 'Albums', icon: 'fa fa-folder', uri: `monochrome/artist/${encodeURIComponent(artistId)}/albums` },
- { service: 'monochrome', type: 'streaming-category', title: 'EPs & Singles', icon: 'fa fa-dot-circle-o', uri: `monochrome/artist/${encodeURIComponent(artistId)}/eps` }
- ]
- }]
- }
- });
- })
- .catch(() => {
- defer.resolve({
- navigation: {
- prev: { uri: 'monochrome/search' },
- lists: [{
- availableListViews: ['list'],
- type: 'title',
- title: `Artist ${artistId}`,
- items: [
- { service: 'monochrome', type: 'streaming-category', title: 'Popular tracks', icon: 'fa fa-fire', uri: `monochrome/artist/${encodeURIComponent(artistId)}/toptracks` },
- { service: 'monochrome', type: 'streaming-category', title: 'Albums', icon: 'fa fa-folder', uri: `monochrome/artist/${encodeURIComponent(artistId)}/albums` },
- { service: 'monochrome', type: 'streaming-category', title: 'EPs & Singles', icon: 'fa fa-dot-circle-o', uri: `monochrome/artist/${encodeURIComponent(artistId)}/eps` }
- ]
- }]
- }
- });
- });
- return defer.promise;
- }
- // Artist popular tracks
- if (curUri.startsWith('monochrome/artist/') && curUri.endsWith('/toptracks')) {
- const artistId = decodeURIComponent(curUri.substring('monochrome/artist/'.length, curUri.lastIndexOf('/toptracks')));
- const limit = parseInt(self.config.get('search_limit') || 20, 10);
- Promise.resolve()
- .then(() => self.api.getArtistMetadata ? self.api.getArtistMetadata(artistId) : null)
- .then((data) => {
- const artist = (data && data.artist) ? data.artist : { name: '' };
- const artistName = artist.name || '';
- if (!artistName) throw new Error('Missing artist name');
- return Promise.all([artistName, self.api.searchTracks(artistName)]);
- })
- .then(([artistName, res]) => {
- const items = (res && Array.isArray(res.items)) ? res.items : [];
- const tracks = items
- .filter(t => t.artist && t.artist.name && t.artist.name.toLowerCase().includes(artistName.toLowerCase()))
- .slice(0, limit)
- .map(t => ({
- service: 'monochrome',
- type: 'song',
- title: t.title || 'Unknown Track',
- artist: t.artist.name || '',
- album: (t.album && t.album.title) || '',
- albumart: self.api.getCoverUrl ? self.api.getCoverUrl(t.cover, '320') : undefined,
- uri: 'monochrome://play/' + t.id,
- icon: 'fa fa-music',
- duration: t.duration || 0
- }));
- defer.resolve({
- navigation: {
- prev: { uri: 'monochrome/artist/' + encodeURIComponent(artistId) },
- lists: [{
- availableListViews: ['list'],
- type: 'title',
- title: 'Popular tracks',
- items: tracks
- }]
- }
- });
- })
- .catch((e) => {
- self.logger.error('[Monochrome] artist toptracks error: ' + e.message);
- defer.resolve({ navigation: { lists: [] } });
- });
- return defer.promise;
- }
- // Artist albums / EPs
- if (curUri.startsWith('monochrome/artist/') && (curUri.endsWith('/albums') || curUri.endsWith('/eps'))) {
- const isEps = curUri.endsWith('/eps');
- const suffix = isEps ? '/eps' : '/albums';
- const artistId = decodeURIComponent(curUri.substring('monochrome/artist/'.length, curUri.lastIndexOf(suffix)));
- const limit = parseInt(self.config.get('search_limit') || 20, 10);
- const epWords = [' ep', '(ep', 'single', 'singles'];
- Promise.resolve()
- .then(() => self.api.getArtistMetadata ? self.api.getArtistMetadata(artistId) : null)
- .then((data) => {
- const artist = (data && data.artist) ? data.artist : { name: '' };
- const artistName = artist.name || '';
- if (!artistName) throw new Error('Missing artist name');
- return Promise.all([artistName, self.api.searchAlbums(artistName)]);
- })
- .then(([artistName, res]) => {
- let items = (res && Array.isArray(res.items)) ? res.items : [];
- // ---------- SORTOWANIE ALBUMÓW W WIDOKU ARTYSTY ----------
- const sortMode = self.configObject?.album_sort || self.config.get('album_sort') || 'newest';
- if (items.length > 0) {
- items = items.map(item => {
- let timestamp = 0;
- if (item.releaseDate) {
- const d = new Date(item.releaseDate);
- timestamp = d.getTime() || 0;
- }
- return { ...item, _timestamp: timestamp };
- });
- if (sortMode === 'newest') {
- items.sort((a, b) => b._timestamp - a._timestamp);
- } else if (sortMode === 'oldest') {
- items.sort((a, b) => a._timestamp - b._timestamp);
- } else if (sortMode === 'title') {
- items.sort((a, b) => {
- const titleA = (a.title || '').toLowerCase();
- const titleB = (b.title || '').toLowerCase();
- return titleA.localeCompare(titleB);
- });
- }
- }
- if (isEps) {
- items = items.filter(a => {
- const title = String(a.title || '').toLowerCase();
- return epWords.some(w => title.includes(w));
- });
- }
- const albums = items.slice(0, limit).map(a => {
- const id = a.id || a.albumId || a.itemId;
- // ---------- DODANIE ROKU DO TYTUŁU ----------
- let title = a.title || (isEps ? 'EP / Single' : 'Album');
- if (a.releaseDate) {
- const year = new Date(a.releaseDate).getFullYear();
- if (!isNaN(year)) title += ` (${year})`;
- }
- return {
- service: 'monochrome',
- type: 'folder',
- title: title,
- artist: (a.artist && a.artist.name) ? a.artist.name : artistName,
- albumart: self.api.getCoverUrl ? self.api.getCoverUrl(a.cover, '320') : undefined,
- uri: id ? ('monochrome/album/' + id) : 'monochrome',
- icon: isEps ? 'fa fa-dot-circle-o' : 'fa fa-folder'
- };
- });
- defer.resolve({
- navigation: {
- prev: { uri: 'monochrome/artist/' + encodeURIComponent(artistId) },
- lists: [{
- availableListViews: ['list'],
- type: 'title',
- title: isEps ? 'EPs & Singles' : 'Albums',
- items: albums
- }]
- }
- });
- })
- .catch((e) => {
- self.logger.error('[Monochrome] artist albums/eps error: ' + e.message);
- defer.resolve({ navigation: { lists: [] } });
- });
- return defer.promise;
- }
- defer.resolve({ navigation: { lists: [] } });
- return defer.promise;
- };
- // ------------------------------------------------------------------
- // SEARCH METHODS – Native Promises (async/await)
- // ------------------------------------------------------------------
- ControllerMonochrome.prototype.searchTracks = async function (query) {
- const self = this;
- const term = (query && (query.value || query.term)) ? String(query.value || query.term).trim() : '';
- if (!term || term.length < 2) return [];
- let limit = parseInt(self.config.get('search_limit'), 10);
- if (isNaN(limit) || limit < 1) limit = 20;
- try {
- const tracksRes = await self.api.searchTracks(term, { limit });
- const tracks = (tracksRes.items || []).slice(0, limit).filter(Boolean).map(t => ({
- service: 'monochrome',
- type: 'song',
- title: t.title || 'Unknown Track',
- name: t.title || 'Unknown Track',
- artist: (t.artist && t.artist.name) ? t.artist.name : (typeof t.artist === 'string' ? t.artist : 'Unknown Artist'),
- album: (t.album && t.album.title) ? t.album.title : (typeof t.album === 'string' ? t.album : ''),
- albumart: self.api.getCoverUrl ? self.api.getCoverUrl(t.cover, '320') : undefined,
- uri: 'monochrome://play/' + String(t.id),
- icon: 'fa fa-music',
- duration: t.duration || 0
- }));
- console.log('[Monochrome] searchTracks items:', tracksRes.items.map(t => ({ id: t.id, title: t.title })));
- const lists = [];
- if (tracks.length) lists.push({ title: 'Tracks', availableListViews: ['list'], items: tracks });
- return lists;
- } catch (error) {
- self.logger.error('[Monochrome] searchTracks error: ' + error.message);
- return [];
- }
- };
- ControllerMonochrome.prototype.searchAlbums = async function (query) {
- const self = this;
- const term = (query && (query.value || query.term)) ? String(query.value || query.term).trim() : '';
- if (!term || term.length < 2) return [];
- // ---------- POBIERANIE LIMITU ----------
- let limit = self.configObject?.search_limit;
- if (typeof limit !== 'number' || limit < 1) {
- limit = parseInt(self.config.get('search_limit'), 10);
- }
- if (isNaN(limit) || limit < 1) limit = 20;
- // ---------- POBIERANIE USTAWIENIA SORTOWANIA ----------
- const sortMode = self.configObject?.album_sort || self.config.get('album_sort') || 'newest';
- console.log(`[Monochrome] ?? searchAlbums: term="${term}", limit=${limit}, sort=${sortMode}`);
- try {
- const result = await self.api.searchAlbums(term, { limit });
- let items = (result && Array.isArray(result.items)) ? result.items : [];
- console.log(`[Monochrome] ?? searchAlbums: raw items = ${items.length}`);
- // ---------- SORTOWANIE ----------
- if (items.length > 0) {
- // Najpierw konwertujemy releaseDate na timestamp dla wydajności
- items = items.map(item => {
- let timestamp = 0;
- if (item.releaseDate) {
- const d = new Date(item.releaseDate);
- timestamp = d.getTime() || 0; // jeśli nieprawidłowa data -> 0
- }
- return { ...item, _timestamp: timestamp };
- });
- // Sortowanie w zależności od wybranej opcji
- if (sortMode === 'newest') {
- items.sort((a, b) => b._timestamp - a._timestamp); // najnowsze pierwsze
- } else if (sortMode === 'oldest') {
- items.sort((a, b) => a._timestamp - b._timestamp); // najstarsze pierwsze
- } else if (sortMode === 'title') {
- items.sort((a, b) => {
- const titleA = (a.title || '').toLowerCase();
- const titleB = (b.title || '').toLowerCase();
- return titleA.localeCompare(titleB);
- });
- }
- }
- // ---------- MAPOWANIE NA FORMAT VOLUMIO ----------
- const albums = items.slice(0, limit).filter(Boolean).map(a => {
- let title = a.title || 'Unknown Album';
- if (a.releaseDate) {
- const year = new Date(a.releaseDate).getFullYear();
- if (!isNaN(year)) title += ` (${year})`;
- }
- return {
- service: 'monochrome',
- type: 'folder',
- title: title,
- artist: (a.artist && a.artist.name) ? a.artist.name : (typeof a.artist === 'string' ? a.artist : ''),
- albumart: (() => {
- const url = self.api.getCoverUrl ? self.api.getCoverUrl(a.cover, '320') : null;
- return url || '/albumart?sourceicon=music_service/monochrome/icon.png';
- })(),
- uri: (a.id || a.albumId || a.itemId) ? ('monochrome/album/' + (a.id || a.albumId || a.itemId)) : 'monochrome',
- icon: 'fa fa-folder'
- };
- });
- console.log(`[Monochrome] ?? searchAlbums: returning ${albums.length} albums (sorted: ${sortMode})`);
- return [{ title: 'Albums', availableListViews: ['list'], items: albums }];
- } catch (error) {
- self.logger.error('[Monochrome] searchAlbums error: ' + error.message);
- return [];
- }
- };
- ControllerMonochrome.prototype.searchArtists = async function (query) {
- const self = this;
- const term = (query && (query.value || query.term)) ? String(query.value || query.term).trim() : '';
- if (!term || term.length < 2) return [];
- let limit = self.configObject?.search_limit;
- if (typeof limit !== 'number' || limit < 1) {
- limit = parseInt(self.config.get('search_limit'), 10);
- }
- if (isNaN(limit) || limit < 1) limit = 20;
- console.log(`[Monochrome] searchArtists: term="${term}", limit=${limit}`);
- try {
- const result = await self.api.searchArtists(term, { limit });
- const items = (result && Array.isArray(result.items)) ? result.items : [];
- console.log(`[Monochrome] searchArtists: raw items = ${items.length}`);
- const artists = items.slice(0, limit).filter(Boolean).map(a => {
- const id = a.id || a.artistId || a.itemId;
- return {
- service: 'monochrome',
- type: 'folder',
- title: a.name || 'Unknown Artist',
- albumart: (() => {
- const coverId = a.picture || a.cover || null;
- console.log(`[Monochrome] Artist "${a.name}" coverId:`, coverId);
- const url = self.api.getCoverUrl ? self.api.getCoverUrl(coverId, '320') : null;
- return url || '/albumart?sourceicon=music_service/monochrome/icon.png';
- })(),
- uri: id ? ('monochrome/artist/' + id) : 'monochrome',
- icon: 'fa fa-user'
- };
- });
- console.log(`[Monochrome] searchArtists: returning ${artists.length} artists`);
- return [{ title: 'Artists', availableListViews: ['list'], items: artists }];
- } catch (error) {
- self.logger.error('[Monochrome] searchArtists error: ' + error.message);
- return [];
- }
- };
- ControllerMonochrome.prototype.searchPlaylists = async function (query) {
- const self = this;
- const term = (query && (query.value || query.term)) ? String(query.value || query.term).trim() : '';
- console.log('[Monochrome] searchPlaylists called with term:', term); // ‹ log na początku
- if (!term || term.length < 2) {
- console.log('[Monochrome] searchPlaylists: term too short, returning []');
- return [];
- }
- // Pobranie limitu z konfiguracji
- let limit = self.configObject?.search_limit;
- if (typeof limit !== 'number' || limit < 1) {
- limit = parseInt(self.config.get('search_limit'), 10);
- }
- if (isNaN(limit) || limit < 1) limit = 20;
- console.log('[Monochrome] searchPlaylists limit:', limit);
- try {
- const result = await self.api.searchPlaylists(term, { limit });
- console.log('[Monochrome] searchPlaylists result from API:', result); // log całego wyniku
- const items = (result && Array.isArray(result.items)) ? result.items : [];
- console.log('[Monochrome] searchPlaylists items count:', items.length);
- if (items.length > 0) {
- console.log('[Monochrome] First playlist item keys:', Object.keys(items[0]));
- }
- const playlists = items
- .slice(0, limit)
- .filter(Boolean)
- .map(p => {
- const pid = p.id != null ? String(p.id) : null;
- if (!pid) return null;
- return {
- service: 'monochrome',
- type: 'folder',
- title: p.title || p.name || 'Playlist',
- albumart: self.api.getCoverUrl ? self.api.getCoverUrl(p.cover || p.image, 320) : undefined,
- uri: `monochrome/playlist/${encodeURIComponent(pid)}`,
- icon: 'fa fa-list'
- };
- })
- .filter(Boolean);
- console.log('[Monochrome] searchPlaylists returning', playlists.length, 'items');
- return [{ title: 'Playlists', availableListViews: ['list'], items: playlists }];
- } catch (error) {
- self.logger.error('[Monochrome] searchPlaylists error: ' + error.message);
- return [];
- }
- };
- // ------------------------------------------------------------------
- // GLOBAL SEARCH – aggregator (native Promise)
- // ------------------------------------------------------------------
- ControllerMonochrome.prototype.search = function (query) {
- const self = this;
- const defer = libQ.defer();
- const term = (query && (query.value || query.term)) ? String(query.value || query.term).trim() : '';
- if (!term || term.length < 2) {
- defer.resolve([]);
- return defer.promise;
- }
- const promises = [];
- // Tracks – zawsze włączone
- promises.push(self.searchTracks({ value: term }).catch(() => []));
- // Albums – jeśli włączone w konfiguracji
- if (self.configObject?.enable_album_search) {
- promises.push(self.searchAlbums({ value: term }).catch(() => []));
- }
- // Artists – jeśli włączone
- if (self.configObject?.enable_artist_search) {
- promises.push(self.searchArtists({ value: term }).catch(() => []));
- }
- // Playlists – jeśli włączone
- if (self.configObject?.enable_playlist_search) {
- promises.push(self.searchPlaylists({ value: term }).catch(() => []));
- }
- Promise.all(promises)
- .then(results => {
- const allLists = results.flat().filter(Boolean);
- console.log(`[Monochrome] ?? Global search collected ${allLists.length} result sections`);
- defer.resolve(allLists);
- })
- .catch(err => {
- self.logger.error('[Monochrome] Global search failed:', err.message);
- defer.resolve([]); // zwracamy pustą tablicę, żeby nie rozwalać UI
- });
- return defer.promise;
- };
- // ------------------------------------------------------------------
- // EXPLODE URI – odtwarzanie
- // ------------------------------------------------------------------
- ControllerMonochrome.prototype.explodeUri = function (uri) {
- const self = this;
- const defer = libQ.defer();
- self.logger.info('[Monochrome] explodeUri ' + uri);
- if (!uri || typeof uri !== 'string') {
- defer.reject(new Error('Invalid URI'));
- return defer.promise;
- }
- // Rehydratacja: jeżeli już jest URL, zwróć jako track dla MPD (bez odtwarzania!)
- if (uri.startsWith('http://') || uri.startsWith('https://')) {
- defer.resolve([{
- service: 'mpd',
- type: 'song',
- uri: uri,
- title: 'Monochrome Stream',
- name: 'Monochrome Stream'
- }]);
- return defer.promise;
- }
- // Obsługa playlisty Spotify (importowanej)
- if (uri.startsWith('monochrome_spotifypl:')) {
- const playlistId = uri.substring('monochrome_spotifypl:'.length);
- const fav = self.loadFavoritesData();
- const imp = fav.spotifyImported && fav.spotifyImported[playlistId];
- if (!imp) {
- defer.reject(new Error('Spotify playlist not found'));
- return defer.promise;
- }
- const trackPromises = (imp.tracks || [])
- .filter(x => x && x.mapped && x.mapped.id)
- .map(x =>
- self.api.getTrackStream(x.mapped.id, 'LOSSLESS')
- .then(streamInfo => ({
- service: 'mpd',
- type: 'song',
- uri: streamInfo.url,
- title: x.mapped.title || x.spotify.title || 'Unknown Track',
- name: x.mapped.title || x.spotify.title || 'Unknown Track',
- artist: x.mapped.artist || (x.spotify.artists || []).join(', '),
- album: x.mapped.album || x.spotify.album || '',
- albumart: (() => {
- const coverId = x.mapped.cover || x.spotify.cover || imp.cover;
- return self.api.getCoverUrl ? self.api.getCoverUrl(coverId, 1280) : coverId;
- })(),
- duration: x.mapped.duration || 0,
- trackType: streamInfo._sourceName,
- codec: streamInfo.trackType,
- icon: streamInfo._sourceIcon || '/albumart?sourceicon=music_service/monochrome/icon.png'
- }))
- .catch(err => {
- self.logger.error(`[Monochrome] Skipping Spotify track ${x.mapped.id} due to error: ${err.message}`);
- return null; // pomijamy ten utwór
- })
- );
- Promise.allSettled(trackPromises)
- .then(results => {
- const tracks = results
- .filter(r => r.status === 'fulfilled' && r.value !== null)
- .map(r => r.value);
- if (tracks.length === 0) {
- self.commandRouter.pushToastMessage('warning', 'Monochrome', 'No playable tracks in this playlist');
- defer.resolve([]); // pusta kolejka – nie przerywa działania
- } else {
- defer.resolve(tracks);
- }
- })
- .catch(e => {
- self.logger.error('[Monochrome] explodeUri Spotify playlist error: ' + e.message);
- defer.reject(e);
- });
- return defer.promise;
- }
- // ----- NOWE: Obsługa całej playlisty -----
- if (uri.startsWith('monochrome://playlist/') || uri.startsWith('monochrome/playlist/')) {
- let playlistId = uri.replace(/^monochrome:\/\/playlist\//, '').replace(/^monochrome\/playlist\//, '');
- try { playlistId = decodeURIComponent(playlistId); } catch (e) {}
- self.api.getPlaylist(playlistId)
- .then((res) => {
- const trackPromises = (res.tracks || [])
- .filter(Boolean)
- .map(t =>
- self.api.getTrackStream(t.id || t.trackId || t.itemId, 'LOSSLESS')
- .then(streamInfo => ({
- service: 'mpd',
- type: 'song',
- uri: streamInfo.url,
- title: t.title || 'Unknown Track',
- name: t.title || 'Unknown Track',
- artist: (t.artist && t.artist.name) ? t.artist.name : '',
- album: (t.album && t.album.title) ? t.album.title : '',
- albumart: self.api.getCoverUrl ? self.api.getCoverUrl(t.cover, '1280') : undefined,
- duration: t.duration || 0,
- trackType: streamInfo._sourceName,
- codec: streamInfo.trackType,
- icon: streamInfo._sourceIcon || '/albumart?sourceicon=music_service/monochrome/icon.png'
- }))
- .catch(err => {
- self.logger.error(`[Monochrome] Skipping track ${t.id} due to error: ${err.message}`);
- return null; // pomijamy ten utwór
- })
- );
- Promise.allSettled(trackPromises)
- .then(results => {
- const tracks = results
- .filter(r => r.status === 'fulfilled' && r.value !== null)
- .map(r => r.value);
- if (tracks.length === 0) {
- defer.reject(new Error('No playable tracks in this playlist'));
- } else {
- defer.resolve(tracks);
- }
- })
- .catch(e => defer.reject(e));
- })
- .catch(e => {
- self.logger.error('[Monochrome] explodeUri playlist error: ' + e.message);
- defer.reject(e);
- });
- return defer.promise;
- }
- // ----- NOWE: Obsługa całego albumu -----
- if (uri.startsWith('monochrome://album/') || uri.startsWith('monochrome/album/')) {
- let albumId = uri.replace(/^monochrome:\/\/album\//, '').replace(/^monochrome\/album\//, '');
- try { albumId = decodeURIComponent(albumId); } catch (e) {}
- self.api.getAlbum(albumId)
- .then((res) => {
- const promises = (res.tracks || [])
- .filter(Boolean)
- .map(t =>
- self.api.getTrackStream(t.id || t.trackId || t.itemId, 'LOSSLESS')
- .then(streamInfo => ({
- service: 'mpd',
- type: 'song',
- uri: streamInfo.url,
- title: t.title || 'Unknown Track',
- name: t.title || 'Unknown Track',
- artist: (t.artist && t.artist.name) ? t.artist.name : '',
- album: (t.album && t.album.title) ? t.album.title : '',
- albumart: self.api.getCoverUrl ? self.api.getCoverUrl(t.cover, '1280') : undefined,
- duration: t.duration || 0,
- trackType: streamInfo._sourceName,
- codec: streamInfo.trackType,
- icon: streamInfo._sourceIcon || '/albumart?sourceicon=music_service/monochrome/icon.png'
- }))
- );
- Promise.all(promises)
- .then(tracks => defer.resolve(tracks))
- .catch(e => defer.reject(e));
- })
- .catch(e => {
- self.logger.error('[Monochrome] explodeUri album error: ' + e.message);
- defer.reject(e);
- });
- return defer.promise;
- }
- let clean = String(uri).trim();
- // Znormalizuj schemat na postać ścieżki
- if (clean.startsWith('monochrome://')) {
- clean = clean.replace('monochrome://', 'monochrome/');
- }
- // Usuń prefix serwisu
- if (clean.startsWith('monochrome/')) {
- clean = clean.substring('monochrome/'.length);
- } else if (clean.startsWith('monochrome')) {
- // awaryjnie: jeśli ktoś da "monochromeplay/123" itp.
- clean = clean.substring('monochrome'.length);
- }
- clean = clean.replace(/^\/+/, '');
- const parts = clean.split('/').filter(Boolean);
- const kind = parts[0]; // play / add
- const rawId = parts[1];
- if ((kind !== 'play' && kind !== 'add') || !rawId) {
- defer.reject(new Error('Invalid URI ' + uri));
- return defer.promise;
- }
- let id = rawId;
- try {
- id = decodeURIComponent(rawId);
- } catch (e) {
- // jeśli nie było encodowane, zostaw jak jest
- id = rawId;
- }
- const quality =
- (self.configObject && self.configObject.quality) ||
- (self.config ? self.config.get('quality') : null) ||
- 'LOSSLESS';
- self.api.getTrackStream(id, quality)
- .then((streamInfo) => {
- self.currentTrack = streamInfo;
- if (!streamInfo || !streamInfo.url) {
- throw new Error('Missing stream URL for id=' + id);
- }
- let srText = streamInfo.samplerateText || streamInfo.samplerate;
- let bdText = streamInfo.bitdepthText || streamInfo.bitdepth;
- if (streamInfo.trackType === 'mp4') {
- srText = null;
- bdText = streamInfo.bitrate ? `${streamInfo.bitrate} kbps` : 'AAC';
- }
- console.log('[Monochrome] streamInfo._sourceIcon =', streamInfo._sourceIcon);
- console.log('[Monochrome] icon final =', streamInfo._sourceIcon || '/albumart?sourceicon=music_service/monochrome/icon.png');
- const trackItem = {
- service: 'mpd',
- type: 'song',
- uri: streamInfo.url,
- title: streamInfo.title,
- name: streamInfo.title,
- artist: streamInfo.artist,
- album: streamInfo.album,
- albumart: streamInfo.albumart,
- duration: streamInfo.duration || 0,
- trackType: streamInfo._sourceName,
- //samplerate: srText,
- // bitdepth: bdText,
- icon: streamInfo._sourceIcon || '/albumart?sourceicon=music_service/monochrome/assets/icon.png',
- codec: streamInfo.trackType
- };
- // AAC/MP4 (Monochrome API: trackType 'm4a' dla lossy)
- if (String(streamInfo.trackType).toLowerCase() === 'aac') {
- // samplerate z API (u Ciebie dla lossy: 44100)
- trackItem.samplerate = self._hzToKhzText(streamInfo.samplerate || 44100) || '44,1 kHz';
- // pokaż bitrate w miejscu "bitdepth"
- trackItem.bitdepth = '320 kbps';
- // opcjonalnie (żeby było jasne)
- // trackItem.codec = 'aac';
- } else {
- // FLAC zostawiamy w spokoju, jak prosiłeś
- }
- // KLUCZ: żadnego play/add przez mpc tutaj.
- defer.resolve([trackItem]);
- })
- .catch((e) => {
- self.logger.error('[Monochrome] explodeUri error: ' + (e && e.message ? e.message : e));
- defer.reject(e);
- });
- return defer.promise;
- };
- // ------------------------------------------------------------------
- // TRANSPORT
- // ------------------------------------------------------------------
- ControllerMonochrome.prototype.play = function() {
- return libQ.resolve();
- };
- ControllerMonochrome.prototype.pause = function() {
- return this.commandRouter.volumioPause();
- };
- ControllerMonochrome.prototype.stop = function() {
- return this.commandRouter.volumioStop();
- };
- ControllerMonochrome.prototype.resume = function() {
- return this.commandRouter.volumioPlay();
- };
- ControllerMonochrome.prototype.seek = function(position) {
- return this.commandRouter.volumioSeek(position);
- };
- ControllerMonochrome.prototype.next = function() {
- return this.commandRouter.volumioNext();
- };
- ControllerMonochrome.prototype.previous = function() {
- return this.commandRouter.volumioPrevious();
- };
- // ------------------------------------------------------------------
- // UI CONFIG – BEZPOŚREDNI ZAPIS/ODCZYT Z PLIKU
- // ------------------------------------------------------------------
- // ------------------------------------------------------------------
- // UI CONFIG – pełna obsługa z fallbackiem i ustawianiem po indeksach
- // ------------------------------------------------------------------
- ControllerMonochrome.prototype.getUIConfig = function () {
- const self = this;
- const defer = libQ.defer();
- const lang_code = this.commandRouter.sharedVars.get('language_code');
- this.commandRouter.i18nJson(
- path.join(__dirname, 'i18n', 'strings_' + lang_code + '.json'),
- path.join(__dirname, 'i18n', 'strings_en.json'),
- path.join(__dirname, 'UIConfig.json')
- ).then((uiconf) => {
- // Sukces – wypełniamy UI wartościami
- self._populateUIConfig(uiconf);
- defer.resolve(uiconf);
- }).fail((e) => {
- // Jeśli i18nJson zawiedzie (brak plików tłumaczeń), ładujemy bezpośrednio UIConfig.json
- console.log('[Monochrome] i18nJson failed, falling back to direct require');
- try {
- const uiconf = require('./UIConfig.json');
- self._populateUIConfig(uiconf);
- defer.resolve(uiconf);
- } catch (e2) {
- console.error('[Monochrome] Direct require also failed:', e2.message);
- defer.resolve({ sections: [] }); // Pusta konfiguracja – GUI się nie wyłoży
- }
- });
- return defer.promise;
- };
- ControllerMonochrome.prototype._populateUIConfig = function (uiconf) {
- const self = this;
- // Odczytaj konfigurację z self.configObject (aktualna w pamięci) lub pliku
- let config = self.configObject;
- if (!config) {
- if (!self.configFile) {
- self.configFile = self.commandRouter.pluginManager.getConfigurationFile(self.context, 'config.json');
- }
- try {
- config = fs.readJsonSync(self.configFile);
- } catch (e) {
- config = {};
- }
- }
- // Domyślne wartości – zabezpieczenie na wypadek braku pól w config
- const defaultInstances = [
- 'https://api.monochrome.tf',
- 'https://arran.monochrome.tf',
- 'https://triton.squid.wtf'
- ];
- const instances = (Array.isArray(config.instances) && config.instances.length)
- ? config.instances
- : defaultInstances;
- const qobuz_api_base = config.qobuz_api_base || 'https://qobuz.squid.wtf/api';
- const source = (config.source && ['tidal', 'qobuz', 'auto'].includes(config.source))
- ? config.source
- : 'tidal';
- const quality = (typeof config.quality === 'string' && config.quality.trim() !== '')
- ? config.quality
- : 'LOSSLESS';
- const search_limit = (typeof config.search_limit === 'number') ? config.search_limit : 20;
- const timeout = (typeof config.timeout === 'number') ? config.timeout : 7000;
- const album_sort = (config.album_sort && ['newest', 'oldest', 'title'].includes(config.album_sort))
- ? config.album_sort
- : 'newest';
- const enable_artist_search = !!config.enable_artist_search;
- const enable_album_search = !!config.enable_album_search;
- const enable_playlist_search = !!config.enable_playlist_search;
- // Pobierz content z sekcji settings
- let content = uiconf.sections[0].content;
- // Jeśli content jest obiektem (a nie tablicą), konwertuj na tablicę
- if (!Array.isArray(content)) {
- console.log('[Monochrome] content is object, converting to array');
- content = Object.values(content);
- uiconf.sections[0].content = content; // zastąp oryginał tablicą
- }
- // Ustaw wartości po indeksach – zgodnie z kolejnością pól w UIConfig.json
- if (content.length > 0) {
- // 0: instances (input text)
- if (content[0]) content[0].value = instances.join(' ');
- // 1: qobuz_api_base (input text)
- if (content[1]) content[1].value = qobuz_api_base;
- // 2: source (select)
- if (content[2]) {
- content[2].value = {
- value: source,
- label: self.getLabelForSelect(content[2].options, source)
- };
- }
- // 3: quality (select)
- if (content[3]) {
- content[3].value = {
- value: quality,
- label: self.getLabelForSelect(content[3].options, quality)
- };
- }
- // 4: search_limit (input number)
- if (content[4]) content[4].value = search_limit;
- // 5: timeout (input number)
- if (content[5]) content[5].value = timeout;
- // 6: album_sort (select)
- if (content[6]) {
- content[6].value = {
- value: album_sort,
- label: self.getLabelForSelect(content[6].options, album_sort)
- };
- }
- // 7: enable_artist_search (switch)
- if (content[7]) content[7].value = enable_artist_search;
- // 8: enable_album_search (switch)
- if (content[8]) content[8].value = enable_album_search;
- // 9: enable_playlist_search (switch)
- if (content[9]) content[9].value = enable_playlist_search;
- }
- // ---------- SEKCJA SPOTIFY ----------
- const spotifySection = uiconf.sections.find(s => s && s.id === 'spotify');
- if (spotifySection) {
- let contentSpotify = spotifySection.content;
- if (!Array.isArray(contentSpotify)) {
- contentSpotify = Object.values(contentSpotify);
- spotifySection.content = contentSpotify;
- }
- const setField = (id, value) => {
- const field = contentSpotify.find(c => c && c.id === id);
- if (field) field.value = value;
- };
- setField('spotify_client_id', config.spotify_client_id || '');
- setField('spotify_client_secret', config.spotify_client_secret || '');
- setField('spotify_playlist_url', config.spotify_playlist_url || '');
- }
- console.log('[Monochrome] _populateUIConfig - done');
- };
- // Funkcja pomocnicza do znajdowania etykiety dla danej wartości selecta
- ControllerMonochrome.prototype.getLabelForSelect = function (options, value) {
- if (!Array.isArray(options)) return value;
- for (let i = 0; i < options.length; i++) {
- if (options[i].value === value) {
- return options[i].label;
- }
- }
- return value; // fallback
- };
- ControllerMonochrome.prototype.setUIConfig = function (data) {
- const self = this;
- const defer = libQ.defer();
- function unwrap(v) {
- if (v && typeof v === 'object' && v.value !== undefined) return v.value;
- return v;
- }
- try {
- console.log('[Monochrome] setUIConfig received:', JSON.stringify(data));
- if (!self.configFile) {
- self.configFile = self.commandRouter.pluginManager.getConfigurationFile(self.context, 'config.json');
- }
- let config = {};
- if (fs.existsSync(self.configFile)) {
- try { config = fs.readJsonSync(self.configFile) || {}; }
- catch (e) { config = {}; }
- }
- // qobuz_api_base
- let qobuzApiBase = unwrap(data.qobuz_api_base);
- if (typeof qobuzApiBase === 'string' && qobuzApiBase.trim() !== '' && qobuzApiBase.trim().startsWith('http')) {
- config.qobuz_api_base = qobuzApiBase.trim();
- }
- // instances
- if (data.instances !== undefined) {
- let instancesRaw = unwrap(data.instances);
- instancesRaw = String(instancesRaw || '').trim();
- const instancesArray = instancesRaw.split(/\s+/).filter(s => s.startsWith('http'));
- config.instances = instancesArray.length ? instancesArray : [
- 'https://api.monochrome.tf',
- 'https://arran.monochrome.tf',
- 'https://triton.squid.wtf'
- ];
- }
- // source
- let sourceValue = unwrap(data.source);
- if (['tidal', 'qobuz', 'auto'].includes(sourceValue)) config.source = sourceValue;
- // quality
- let qualityValue = unwrap(data.quality);
- if (typeof qualityValue === 'string' && qualityValue.trim() !== '') config.quality = qualityValue.trim();
- // search_limit
- if (data.search_limit !== undefined) {
- const v = parseInt(unwrap(data.search_limit), 10);
- if (!isNaN(v)) config.search_limit = v;
- }
- // timeout
- if (data.timeout !== undefined) {
- const v = parseInt(unwrap(data.timeout), 10);
- if (!isNaN(v)) config.timeout = v;
- }
- // album_sort
- let albumSortValue = unwrap(data.album_sort);
- if (['newest', 'oldest', 'title'].includes(albumSortValue)) config.album_sort = albumSortValue;
- // booleans
- ['enable_artist_search', 'enable_album_search', 'enable_playlist_search'].forEach((key) => {
- if (data[key] !== undefined) config[key] = !!unwrap(data[key]);
- });
- // Spotify (jeśli masz w UI)
- if (data.spotify_client_id !== undefined) config.spotify_client_id = String(unwrap(data.spotify_client_id) || '').trim();
- if (data.spotify_client_secret !== undefined) config.spotify_client_secret = String(unwrap(data.spotify_client_secret) || '').trim();
- if (data.spotify_playlist_url !== undefined) config.spotify_playlist_url = String(unwrap(data.spotify_playlist_url) || '').trim();
- fs.writeJsonSync(self.configFile, config, { spaces: 2 });
- console.log('[Monochrome] Config saved to file:', self.configFile);
- self.configObject = config;
- if (!self.config) self.config = new vConf();
- self.config.loadFile(self.configFile);
- const apiSettings = {
- getInstances: async () => config.instances || ['https://api.monochrome.tf'],
- getConf: async (key, defVal) => (Object.prototype.hasOwnProperty.call(config, key) ? config[key] : defVal)
- };
- self.api = new MultiSourceAPI(apiSettings);
- self.commandRouter.pushToastMessage('success', 'Settings Saved', 'Monochrome settings updated.');
- defer.resolve();
- } catch (e) {
- console.error('[Monochrome] setUIConfig error:', e);
- defer.reject(e);
- }
- return defer.promise;
- };
- // ------------------------------------------------------------------
- // METODY POMOCNICZE DLA UI (testy, reset)
- // ------------------------------------------------------------------
- ControllerMonochrome.prototype.testConnection = async function () {
- const self = this;
- try {
- const results = await self.api.testConnection();
- const working = results.filter(r => r.status === 'OK').length;
- const failed = results.filter(r => r.status !== 'OK').length;
- self.commandRouter.pushToastMessage('info', 'Monochrome', `Connection test: ${working} working, ${failed} failed`);
- } catch (e) {
- self.commandRouter.pushToastMessage('error', 'Monochrome', 'Test failed: ' + e.message);
- }
- };
- ControllerMonochrome.prototype.clearCache = function () {
- this.api.clearCache();
- this.commandRouter.pushToastMessage('success', 'Monochrome', 'Cache cleared');
- };
- ControllerMonochrome.prototype.resetPlugin = function () {
- const self = this;
- try {
- if (fs.existsSync(self.configFile)) {
- fs.unlinkSync(self.configFile);
- }
- self.commandRouter.pushToastMessage('success', 'Monochrome', 'Plugin reset, please restart Volumio');
- } catch (e) {
- self.commandRouter.pushToastMessage('error', 'Monochrome', 'Reset failed: ' + e.message);
- }
- };
- ControllerMonochrome.prototype.isFavoritePlaylist = function (playlistId) {
- const favs = this.loadFavoritePlaylists();
- return favs.some(p => String(p.id) === String(playlistId));
- };
- ControllerMonochrome.prototype.getFavoritesFilePath = function () {
- if (!this.configFile) {
- this.configFile = this.commandRouter.pluginManager.getConfigurationFile(this.context, 'config.json');
- }
- return this.configFile.replace('config.json', FAVORITES_FILE);
- };
- ControllerMonochrome.prototype.loadFavoritePlaylists = function () {
- const file = this.getFavoritesFilePath();
- console.log('[Monochrome] favorites file path:', file);
- if (!fs.existsSync(file)) {
- console.log('[Monochrome] favorites file missing');
- return [];
- }
- try {
- const data = fs.readJsonSync(file);
- console.log('[Monochrome] favorites keys:', Object.keys(data || {}));
- console.log('[Monochrome] favorites playlists length:', Array.isArray(data?.playlists) ? data.playlists.length : 'NOT_ARRAY');
- return Array.isArray(data.playlists) ? data.playlists : [];
- } catch (e) {
- console.log('[Monochrome] favorites readJsonSync error:', e.message);
- return [];
- }
- };
- ControllerMonochrome.prototype.saveFavoritePlaylists = function (playlists) {
- const file = this.getFavoritesFilePath();
- let data = {};
- try { if (fs.existsSync(file)) data = fs.readJsonSync(file) || {}; } catch (e) {}
- data.playlists = playlists || [];
- fs.writeJsonSync(file, data, { spaces: 2 });
- };
- ControllerMonochrome.prototype.toggleFavoritePlaylist = function (playlistObj) {
- // playlistObj: { id, title, uri, cover, type } – type może być 'tidal' lub 'spotify'
- const favs = this.loadFavoritePlaylists();
- const pid = String(playlistObj.id);
- const idx = favs.findIndex(p => String(p.id) === pid);
- if (idx >= 0) {
- favs.splice(idx, 1);
- this.saveFavoritePlaylists(favs);
- return { added: false };
- }
- favs.unshift({
- id: pid,
- title: playlistObj.title,
- uri: playlistObj.uri,
- cover: playlistObj.cover || null,
- type: playlistObj.type || 'tidal', // domyślnie tidal, dla Spotify ustawiamy 'spotify'
- autoRefresh: playlistObj.autoRefresh || false, // domyślnie false
- lastRefreshed: playlistObj.type === 'spotify' ? new Date().toISOString() : null
- });
- this.saveFavoritePlaylists(favs);
- return { added: true };
- };
- ControllerMonochrome.prototype.importSpotifyPlaylistNow = async function () {
- const self = this;
- try {
- self.logger.info('[Monochrome] Starting Spotify import');
- const spotifyUrl = self.configObject?.spotify_playlist_url || self.config.get('spotify_playlist_url');
- if (!spotifyUrl) {
- self.logger.error('[Monochrome] No Spotify playlist URL provided');
- self.commandRouter.pushToastMessage('error', 'Monochrome', 'No Spotify playlist URL provided');
- return;
- }
- const clientId = self.configObject?.spotify_client_id || self.config.get('spotify_client_id');
- const clientSecret = self.configObject?.spotify_client_secret || self.config.get('spotify_client_secret');
- const importer = require('./spotify_importer');
- const playlistId = importer.parsePlaylistId(spotifyUrl);
- self.logger.info('[Monochrome] Spotify import: playlist ID = ' + playlistId);
- let meta, tracks;
- let usedFallback = false;
- if (clientId && clientSecret) {
- try {
- self.logger.info('[Monochrome] Trying official Spotify API...');
- const token = await importer.getAppToken(clientId, clientSecret);
- meta = await importer.getPlaylistMeta(playlistId, token);
- tracks = await importer.getPlaylistTracksAll(playlistId, token);
- self.logger.info(`[Monochrome] Official API succeeded: ${tracks.length} tracks`);
- } catch (apiError) {
- if (apiError.message.includes('404') || apiError.message.includes('Resource not found')) {
- self.logger.warn('[Monochrome] Official API returned 404, falling back to guest method...');
- usedFallback = true;
- } else {
- throw apiError;
- }
- }
- } else {
- self.logger.info('[Monochrome] No Spotify credentials, using guest fallback directly');
- usedFallback = true;
- }
- if (usedFallback) {
- try {
- const fallbackResult = await self._fetchSpotifyViaRust(spotifyUrl);
- meta = {
- id: playlistId,
- title: fallbackResult.title || 'Imported Playlist',
- cover: fallbackResult.cover || null
- };
- tracks = fallbackResult.tracks.map(t => ({
- title: t.title,
- artists: t.artists,
- isrc: t.isrc || null,
- spotifyUri: t.spotifyUri || null,
- album: t.album || '',
- cover: t.cover || null
- }));
- self.logger.info(`[Monochrome] Guest fallback succeeded: ${tracks.length} tracks`);
- } catch (fallbackError) {
- self.logger.error('[Monochrome] Guest fallback also failed: ' + fallbackError.message);
- throw new Error('Both official API and guest fallback failed');
- }
- }
- // Mapowanie do Tidal/Qobuz (bez zmian)
- const mapped = [];
- for (const t of tracks) {
- const q = `${(t.artists[0] || '').trim()} ${t.title}`.trim();
- if (!q) {
- mapped.push({ spotify: t, mapped: null });
- continue;
- }
- try {
- const res = await self.api.searchTracks(q, { limit: 5 });
- const best = res?.items?.[0] || null;
- mapped.push({
- spotify: t,
- mapped: best ? {
- id: String(best.id),
- title: best.title,
- artist: best.artist?.name,
- album: best.album?.title,
- cover: best.cover
- } : null
- });
- } catch (e) {
- self.logger.error('[Monochrome] Error mapping track: ' + e.message);
- mapped.push({ spotify: t, mapped: null });
- }
- }
- // Zapis do favorites.json (bez zmian)
- const favFile = self.getFavoritesFilePath();
- let data = {};
- try {
- if (fs.existsSync(favFile)) data = fs.readJsonSync(favFile) || {};
- } catch (e) {
- self.logger.error('[Monochrome] Error reading favorites file: ' + e.message);
- }
- if (!data.playlists) data.playlists = [];
- if (!data.spotifyImported) data.spotifyImported = {};
- const now = new Date().toISOString();
- data.spotifyImported[playlistId] = {
- spotifyUrl,
- title: meta.title,
- cover: meta.cover,
- importedAt: new Date().toISOString(),
- tracks: mapped,
- lastRefreshed: now,
- autoRefresh: false
- };
- const favId = `spotify:${playlistId}`;
- const favUri = `monochrome_spotifypl:${playlistId}`;
- const exists = data.playlists.some(p => String(p.id) === favId);
- if (!exists) {
- data.playlists.unshift({ id: favId, title: meta.title, uri: favUri, cover: meta.cover, type: 'spotify', autoRefresh: false, lastRefreshed: now});
- } else {
- data.playlists = data.playlists.map(p => String(p.id) === favId ? { ...p, title: meta.title, cover: meta.cover, uri: favUri } : p);
- }
- fs.writeJsonSync(favFile, data, { spaces: 2 });
- self.logger.info('[Monochrome] Spotify import completed successfully');
- self.commandRouter.pushToastMessage('success', 'Monochrome', `Imported ${tracks.length} tracks, ${mapped.filter(x => x.mapped).length} matched.`);
- } catch (error) {
- self.logger.error('[Monochrome] Spotify import error: ' + error.message);
- self.commandRouter.pushToastMessage('error', 'Monochrome', 'Import failed: ' + error.message);
- }
- };
- ControllerMonochrome.prototype.loadFavoritesData = function () {
- const file = this.getFavoritesFilePath();
- if (!fs.existsSync(file)) return { playlists: [], spotifyImported: {} };
- try {
- const data = fs.readJsonSync(file) || {};
- if (!Array.isArray(data.playlists)) data.playlists = [];
- if (!data.spotifyImported || typeof data.spotifyImported !== 'object') data.spotifyImported = {};
- return data;
- } catch (e) {
- return { playlists: [], spotifyImported: {} };
- }
- };
- ControllerMonochrome.prototype.saveFavoritesData = function (data) {
- const file = this.getFavoritesFilePath();
- fs.writeJsonSync(file, data || { playlists: [], spotifyImported: {} }, { spaces: 2 });
- };
- ControllerMonochrome.prototype._fetchSpotifyViaRust = async function (playlistUrl) {
- const self = this;
- const binPath = path.join(__dirname, 'bin', 'spotify_fallback');
- try {
- const { stdout, stderr } = await execPromise(`"${binPath}" "${playlistUrl}"`);
- if (stderr) {
- self.logger.warn('[Monochrome] Rust fallback stderr: ' + stderr);
- }
- const result = JSON.parse(stdout);
- if (result.error) throw new Error(result.error);
- return result;
- } catch (err) {
- throw new Error(`Rust fallback failed: ${err.message}`);
- }
- };
- // Sprawdza, czy playlistę należy odświeżyć (autoRefresh włączone i minęły >=2 dni od lastRefreshed)
- ControllerMonochrome.prototype._shouldRefreshPlaylist = function (playlist) {
- if (!playlist.autoRefresh) return false;
- if (!playlist.lastRefreshed) return true; // nigdy nie odświeżana
- const last = new Date(playlist.lastRefreshed);
- const now = new Date();
- const diffDays = (now - last) / (1000 * 60 * 60 * 24);
- return diffDays >= 2;
- };
- // Aktualizuje timestamp ostatniego odświeżenia dla playlisty o podanym ID
- ControllerMonochrome.prototype._updatePlaylistRefreshTime = function (playlistId) {
- const data = this.loadFavoritesData();
- const now = new Date().toISOString();
- const playlistEntry = data.playlists.find(p => String(p.id) === `spotify:${playlistId}`);
- if (playlistEntry) {
- playlistEntry.lastRefreshed = now;
- }
- if (data.spotifyImported && data.spotifyImported[playlistId]) {
- data.spotifyImported[playlistId].lastRefreshed = now;
- }
- this.saveFavoritesData(data);
- };
- ControllerMonochrome.prototype._refreshSingleSpotifyPlaylist = async function (playlistId) {
- const self = this;
- const data = self.loadFavoritesData();
- const existing = data.spotifyImported?.[playlistId];
- if (!existing) {
- throw new Error(`Playlist ${playlistId} not found in spotifyImported`);
- }
- const spotifyUrl = `https://open.spotify.com/playlist/${playlistId}`;
- const fallbackResult = await self._fetchSpotifyViaRust(spotifyUrl);
- const meta = {
- id: playlistId,
- title: fallbackResult.title || existing.title,
- cover: fallbackResult.cover || existing.cover
- };
- const tracks = fallbackResult.tracks.map(t => ({
- title: t.title,
- artists: t.artists,
- isrc: t.isrc || null,
- spotifyUri: t.spotifyUri || null,
- album: t.album || '',
- cover: t.cover || null
- }));
- // Mapowanie do Tidal/Qobuz (identyczne jak w import)
- const mapped = [];
- for (const t of tracks) {
- const q = `${(t.artists[0] || '').trim()} ${t.title}`.trim();
- if (!q) {
- mapped.push({ spotify: t, mapped: null });
- continue;
- }
- try {
- const res = await self.api.searchTracks(q, { limit: 5 });
- const best = res?.items?.[0] || null;
- mapped.push({
- spotify: t,
- mapped: best ? {
- id: String(best.id),
- title: best.title,
- artist: best.artist?.name,
- album: best.album?.title,
- cover: best.cover
- } : null
- });
- } catch (e) {
- self.logger.error('[Monochrome] Error mapping track during refresh: ' + e.message);
- mapped.push({ spotify: t, mapped: null });
- }
- }
- // Aktualizacja istniejącego wpisu
- data.spotifyImported[playlistId] = {
- ...existing,
- title: meta.title,
- cover: meta.cover,
- lastRefreshed: new Date().toISOString(),
- tracks: mapped
- };
- // Aktualizacja również w playlists (jeśli istnieje)
- const favId = `spotify:${playlistId}`;
- const playlistEntry = data.playlists.find(p => String(p.id) === favId);
- if (playlistEntry) {
- playlistEntry.title = meta.title;
- playlistEntry.cover = meta.cover;
- playlistEntry.lastRefreshed = new Date().toISOString();
- }
- self.saveFavoritesData(data);
- self.logger.info(`[Monochrome] Refreshed playlist: ${meta.title} (${playlistId})`);
- };
- // Przechodzi przez wszystkie playlisty i odświeża te, które wymagają odświeżenia
- ControllerMonochrome.prototype._refreshExpiredPlaylists = async function () {
- const self = this;
- self.logger.info('[Monochrome] Checking for expired Spotify playlists to refresh...');
- const data = self.loadFavoritesData();
- const spotifyPlaylists = data.playlists.filter(p => p.type === 'spotify');
- let refreshedCount = 0;
- for (const pl of spotifyPlaylists) {
- if (self._shouldRefreshPlaylist(pl)) {
- const playlistId = pl.id.replace('spotify:', '');
- try {
- self.logger.info(`[Monochrome] Refreshing playlist: ${pl.title} (${playlistId})`);
- await self._refreshSingleSpotifyPlaylist(playlistId);
- refreshedCount++;
- } catch (e) {
- self.logger.error(`[Monochrome] Failed to refresh playlist ${playlistId}: ${e.message}`);
- }
- }
- }
- self.logger.info(`[Monochrome] Refresh check completed. Refreshed ${refreshedCount} playlists.`);
- };
- ControllerMonochrome.prototype._checkMpdState = function () {
- const self = this;
- try {
- // 1) Pobieramy aktualny (tani) stan z Volumio
- const vs = self.commandRouter.volumioGetState();
- if (!vs || vs.service !== 'mpd') return;
- const now = Date.now();
- // 2) Anti-spam (np. timer co 60s chroni przed zapętleniem żądań)
- const cooldownMs = 10 * 60 * 1000; // 10 min
- if (self._lastQueueClearAt && (now - self._lastQueueClearAt) < cooldownMs) return;
- // 3) Odpytujemy MPD prosto z warstwy silnika (libQ/kew)
- self.commandRouter.executeOnPlugin('music_service', 'mpd', 'getState', '')
- .then((state) => {
- if (!state || !state.status) return;
- const s = state.status; // 'play' | 'pause' | 'stop'
- // Loguj tylko zmianę stanu
- if (s !== self.currentState) {
- self.logger.info(`[Monochrome] MPD status: ${self.currentState} -> ${s}`);
- self.currentState = s;
- }
- // 4) Jeżeli odtwarza - sprawdzamy czy to Monochrome. Jeżeli tak - resetujemy licznik czasu.
- if (s === 'play') {
- // W stanie 'play' warunek posiadania odpowiedniego URI ma sens
- if (self._isMonochromeMpdUri(vs.uri)) {
- self.lastPlayTime = now;
- }
- return;
- }
- // 5) Skoro jesteśmy tutaj, urządzenie stoi (pause / stop).
- // Warunek idle liczymy niezależnie od aktualnego "uri" (bo w 'stop' uri mogło spaść)
- const fourHours = 4 * 60 * 60 * 1000;
- const idleTooLong = self.lastPlayTime && (now - self.lastPlayTime > fourHours);
- if ((s === 'pause' || s === 'stop') && idleTooLong) {
- self.logger.info('[Monochrome] Odtwarzacz nieaktywny od ponad 4h -> Twarde czyszczenie kolejki i dysku.');
- self._lastQueueClearAt = now;
- self.lastPlayTime = null; // Zabezpieczenie przed zapętleniem
- try {
- // Najpewniejsza komenda czyszcząca system Volumio (lepsza niż API)
- self.commandRouter.stateMachine.clearQueue();
- self.logger.info('[Monochrome] Kolejka została pomyślnie i bezpowrotnie zrzucona.');
- } catch (e) {
- self.commandRouter.volumioClearQueue();
- self.logger.info('[Monochrome] Kolejka zrzucona poprzez fallback API.');
- }
- // Czyszczenie "śmieci" z dysku DASH
- if (typeof self._cleanUnusedDashFiles === 'function') {
- self._cleanUnusedDashFiles(0).catch(err => {
- self.logger.error('[Monochrome] Błąd czyszczenia plików po bezczynności: ' + (err.message || String(err)));
- });
- }
- }
- })
- .fail((e) => {
- self.logger.error('[Monochrome] mpd getState error: ' + (e?.message || String(e)));
- });
- } catch (e) {
- self.logger.error('[Monochrome] _checkMpdState exception: ' + (e?.message || String(e)));
- }
- };
- ControllerMonochrome.prototype._isMonochromeMpdUri = function (uri) {
- if (!uri || typeof uri !== 'string') return false;
- // 1) Lokalne pliki z temp/dash wtyczki
- if (uri.startsWith('file:///data/plugins/music_service/monochrome/')) return true;
- if (uri.startsWith('/data/plugins/music_service/monochrome/')) return true;
- // 2) Zdalne streamy (Tidal CDN) – dopisz hosty, które u Ciebie realnie występują
- // Przykład z loga: lgf.audio.tidal.com
- if (uri.startsWith('https://') || uri.startsWith('http://')) {
- try {
- const u = new URL(uri);
- const h = (u.hostname || '').toLowerCase();
- // Tidal CDN (często: *.audio.tidal.com, resources.tidal.com to okładki)
- if (h.endsWith('.audio.tidal.com')) return true;
- if (h === 'lgf.audio.tidal.com') return true;
- // jeśli kiedyś dodasz Qobuz bezpośrednio, dopisz tu ich hosty
- } catch (e) {
- // jak URL nie parsuje się, olej
- }
- }
- return false;
- };
- ControllerMonochrome.prototype._hzToKhzText = function (hz) {
- const n = parseInt(hz, 10);
- if (!Number.isFinite(n) || n <= 0) return '';
- const khz = n / 1000;
- const s = (Math.round(khz * 10) / 10).toString().replace('.', ',');
- return s + ' kHz';
- };
- ControllerMonochrome.prototype.getFreeBytesForPath = async function (dirPath) {
- // POSIX df, wynik w bajtach (Available)
- const safe = String(dirPath).replace(/'/g, "'\\''");
- const { stdout } = await execPromise(`df -B1 '${safe}' | tail -1 | awk '{print $4}'`);
- const n = parseInt(String(stdout).trim(), 10);
- return Number.isFinite(n) ? n : 0;
- };
- ControllerMonochrome.prototype.cleanDashIfLowSpace = async function () {
- const self = this;
- const tempDir = 'data/plugins/music_service/monochrome/temp/dash';
- const thresholdBytes = 1 * 1024 * 1024 * 1024; // 1 GB
- let free = await self.getFreeBytesForPath(tempDir);
- if (free >= thresholdBytes) return;
- self.logger.warn(`[Monochrome] Low disk space: free=${(free/1024/1024).toFixed(0)}MB, cleaning DASH...`);
- // Sprzątamy agresywnie: maxAgeMs=0 => usuń wszystko nieużywane niezależnie od wieku
- // (u Ciebie cleanUnusedDashFiles ma logikę: maxAgeMs=0 usuwa nieużywane zawsze)
- for (let i = 0; i < 20; i++) { // limit pętli bezpieczeństwa
- await self._cleanUnusedDashFiles(0);
- const newFree = await self.getFreeBytesForPath(tempDir);
- if (newFree >= thresholdBytes) break;
- if (newFree <= free) break; // nic już nie przybywa -> nie ma co usuwać
- free = newFree;
- }
- self.logger.info(`[Monochrome] DASH cleanup finished, freeNow=${(free/1024/1024).toFixed(0)}MB`);
- };
Advertisement
Add Comment
Please, Sign In to add comment