blackscreener

Volumio monochrome index.js

Apr 15th, 2026
55
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 97.09 KB | None | 0 0
  1. 'use strict';
  2. const exec = require('child_process').exec;
  3. const libQ = require('kew');
  4. const path = require('path');
  5. const vConf = require('v-conf');
  6. const fs = require('fs-extra');
  7.  
  8. const MultiSourceAPI = require('./multi-source-api');
  9. const FAVORITES_FILE = 'favorites.json';
  10.  
  11. fs.ensureDirSync('/data/plugins/music_service/monochrome/temp/dash');
  12.  
  13. const util = require('util');
  14. const execPromise = util.promisify(exec); // używamy istniejącego exec
  15. module.exports = ControllerMonochrome;
  16.  
  17. function ControllerMonochrome(context) {
  18. this.context = context;
  19. this.commandRouter = this.context.coreCommand;
  20. this.logger = this.context.logger;
  21.  
  22. this.config = null;
  23. this.configFile = null;
  24. this.api = null;
  25. this.currentTrack = null;
  26.  
  27. this.lastPlayTime = null; // czas ostatniego odtworzenia
  28. this.currentState = 'stop'; // bieżący stan odtwarzacza
  29. }
  30.  
  31. // ------------------------------------------------------------------
  32. // VOLUMIO LIFECYCLE
  33. // ------------------------------------------------------------------
  34.  
  35. ControllerMonochrome.prototype.onVolumioStart = function (conf) {
  36. this.config = conf;
  37. return libQ.resolve();
  38. };
  39.  
  40. ControllerMonochrome.prototype.onStart = function () {
  41. const self = this;
  42. const defer = libQ.defer();
  43.  
  44. try {
  45. self.configFile = self.commandRouter.pluginManager.getConfigurationFile(self.context, 'config.json');
  46. console.log('[Monochrome] Config file path:', self.configFile);
  47.  
  48. let config = {};
  49. if (fs.existsSync(self.configFile)) {
  50. try {
  51. config = fs.readJsonSync(self.configFile) || {};
  52. console.log('[Monochrome] Config loaded from file');
  53. } catch (e) {
  54. console.log('[Monochrome] Config file corrupted, creating default config');
  55. config = {};
  56. }
  57. }
  58.  
  59. const defaultInstances = [
  60. 'https://api.monochrome.tf',
  61. 'https://arran.monochrome.tf',
  62. 'https://triton.squid.wtf'
  63. ];
  64.  
  65. // Normalizacja + defaulty (underscore)
  66. config.instances = (Array.isArray(config.instances) && config.instances.length)
  67. ? config.instances.filter(i => typeof i === 'string' && i.startsWith('http'))
  68. : defaultInstances;
  69.  
  70. config.qobuz_api_base = (typeof config.qobuz_api_base === 'string' && config.qobuz_api_base.startsWith('http'))
  71. ? config.qobuz_api_base
  72. : 'https://qobuz.squid.wtf/api';
  73.  
  74. config.source = (['tidal', 'qobuz', 'auto'].includes(config.source)) ? config.source : 'tidal';
  75.  
  76. config.quality = (typeof config.quality === 'string' && config.quality.trim() !== '')
  77. ? config.quality.trim()
  78. : 'LOSSLESS';
  79.  
  80. config.search_limit = (typeof config.search_limit === 'number') ? config.search_limit : 20;
  81. config.timeout = (typeof config.timeout === 'number') ? config.timeout : 7000;
  82.  
  83. config.album_sort = (['newest', 'oldest', 'title'].includes(config.album_sort)) ? config.album_sort : 'newest';
  84.  
  85. config.enable_artist_search = !!config.enable_artist_search;
  86. config.enable_album_search = !!config.enable_album_search;
  87. config.enable_playlist_search = !!config.enable_playlist_search;
  88.  
  89. // Spotify (opcjonalnie)
  90. config.spotify_client_id = typeof config.spotify_client_id === 'string' ? config.spotify_client_id : '';
  91. config.spotify_client_secret = typeof config.spotify_client_secret === 'string' ? config.spotify_client_secret : '';
  92. config.spotify_playlist_url = typeof config.spotify_playlist_url === 'string' ? config.spotify_playlist_url : '';
  93.  
  94. fs.writeJsonSync(self.configFile, config, { spaces: 2 });
  95. console.log('[Monochrome] Config saved to file');
  96.  
  97. self.configObject = config;
  98.  
  99.  
  100.  
  101. self.config = new vConf();
  102. self.config.loadFile(self.configFile);
  103.  
  104. const apiSettings = {
  105. getInstances: async () => config.instances || defaultInstances,
  106. getConf: async (key, defVal) => (Object.prototype.hasOwnProperty.call(config, key) ? config[key] : defVal)
  107. };
  108. self.api = new MultiSourceAPI(apiSettings);
  109.  
  110.  
  111. // Monitorowanie stanu MPD co xxxx minutę (tylko Monochrome)
  112. if (!self._mpdMonitorInterval) {
  113. self.logger.info('[Monochrome] Starting MPD monitor (10s)');
  114.  
  115. // odpal raz od razu (nie czekaj 60s)
  116. try { self._checkMpdState(); } catch (e) {}
  117.  
  118. self._mpdMonitorInterval = setInterval(() => {
  119. try { self._checkMpdState(); } catch (e) {}
  120. }, 10000);
  121. }
  122.  
  123.  
  124. self.addToBrowseSources();
  125. self.logger.info('[Monochrome] Started');
  126.  
  127. // Wyczyść kolejkę Volumio podczas startu pluginu (np. po restarcie maliny)
  128. self.commandRouter.volumioClearQueue();
  129. self.logger.info('[Monochrome] Queue cleared on startup');
  130.  
  131. // Sprawdzanie, czy odtwarzacz jest zatrzymany od 2h i czyszczenie kolejki
  132. self._idleQueueClearInterval = setInterval(() => {
  133. if (self.currentState === 'stop' && self.lastPlayTime && (Date.now() - self.lastPlayTime > 2 * 60 * 60 * 1000)) {
  134. self.logger.info('[Monochrome] Odtwarzacz zatrzymany od 2h – czyszczenie kolejki');
  135. self.commandRouter.volumioClearQueue(); // czyści całą kolejkę
  136. self.logger.info('[Monochrome] Kolejka została wyczyszczona');
  137. // Opcjonalnie: po wyczyszczeniu kolejki usuń też nieużywane pliki DASH
  138. self._cleanUnusedDashFiles(0);
  139. } else {
  140. // Logi pomocnicze – możesz je usunąć, gdy nie będą potrzebne
  141. if (self.currentState === 'pause') {
  142. self.logger.debug('[Monochrome] Pauza – nie czyszczę kolejki');
  143. }
  144. }
  145. }, 30 * 60 * 1000); // sprawdzanie co 30 min
  146.  
  147. self._cleanUnusedDashFiles(0).catch(e => self.logger.error(e)); // Przy starcie usuń wszystkie nieużywane pliki (bez względu na wiek)
  148.  
  149.  
  150. self.lowSpaceInterval= setInterval(() => {
  151. self.cleanDashIfLowSpace().catch(e => self.logger.error(e?.message || String(e)));
  152. }, 5 * 60 * 1000);
  153.  
  154.  
  155. // Uruchom serwer proxy DASH
  156. const { execSync } = require('child_process');
  157. try {
  158. execSync('fuser -k 3002/tcp'); // zabija proces na porcie 3002
  159. } catch (e) {
  160. // ignoruj, jeśli port nie był zajęty
  161. }
  162.  
  163. const { fork } = require('child_process');
  164. const proxyPath = path.join(__dirname, 'dash-proxy.js');
  165. self.dashProxy = fork(proxyPath);
  166. self.dashProxy.on('error', (err) => {
  167. self.logger.error('[Monochrome] Dash proxy error:', err);
  168. });
  169. self.dashProxy.on('exit', (code) => {
  170. self.logger.info(`[Monochrome] Dash proxy exited with code ${code}`);
  171. });
  172. self.logger.info('[Monochrome] Dash proxy started');
  173.  
  174.  
  175.  
  176. // co 30 min
  177. self._dashCleanInterval = setInterval(() => {
  178. self._cleanUnusedDashFiles(60 * 60 * 1000).catch(e => self.logger.error(e));
  179. }, 30 * 60 * 1000);
  180.  
  181. // Uruchom pierwsze sprawdzenie
  182. self._refreshExpiredPlaylists().catch(e => self.logger.error(e));
  183.  
  184. // Ustaw interwał co 6 godzin
  185. self._refreshInterval = setInterval(() => {
  186. self._refreshExpiredPlaylists().catch(e => self.logger.error(e));
  187. }, 6 * 60 * 60 * 1000); // co 6 godzin
  188.  
  189. self.addToBrowseSources();
  190. self.logger.info('[Monochrome] Started');
  191. defer.resolve();
  192. } catch (e) {
  193. self.logger.error('[Monochrome] onStart error: ' + (e && e.message ? e.message : e));
  194. defer.reject(e);
  195. }
  196.  
  197. return defer.promise;
  198. };
  199.  
  200.  
  201.  
  202.  
  203. ControllerMonochrome.prototype._cleanUnusedDashFiles = function (maxAgeMs = 60 * 60 * 1000) {
  204. const self = this;
  205.  
  206. const fs = require('fs-extra');
  207. const path = require('path');
  208.  
  209. const tempDir = '/data/plugins/music_service/monochrome/temp/dash';
  210.  
  211. // libQ/kew -> native Promise
  212. const toPromise = (p) =>
  213. new Promise((resolve, reject) => {
  214. try {
  215. if (p && typeof p.then === 'function') {
  216. p.then(resolve).fail ? p.then(resolve).fail(reject) : p.then(resolve, reject);
  217. } else {
  218. resolve(p);
  219. }
  220. } catch (e) {
  221. reject(e);
  222. }
  223. });
  224.  
  225. return toPromise(self.commandRouter.volumioGetQueue())
  226. .then(async (queue) => {
  227. if (!queue || !Array.isArray(queue)) {
  228. self.logger.warn('[Monochrome] Nie można pobrać kolejki do czyszczenia DASH');
  229. return;
  230. }
  231.  
  232. // Pliki używane w kolejce
  233. const usedFiles = new Set();
  234. for (const item of queue) {
  235. if (!item || !item.uri || typeof item.uri !== 'string') continue;
  236.  
  237. if (item.uri.startsWith('file://')) {
  238. // "file:///data/..." -> "/data/..."
  239. let filePath = item.uri.replace(/^file:\/\//, '');
  240. // upewnij się, że nie ma podwójnych slashy na początku
  241. if (filePath.startsWith('/')) {
  242. // ok
  243. } else {
  244. filePath = '/' + filePath;
  245. }
  246. usedFiles.add(path.normalize(filePath));
  247. }
  248. }
  249.  
  250. let files;
  251. try {
  252. files = await fs.readdir(tempDir);
  253. } catch (e) {
  254. self.logger.warn('[Monochrome] Brak katalogu DASH lub brak dostępu: ' + tempDir);
  255. return;
  256. }
  257.  
  258. const now = Date.now();
  259. let removed = 0;
  260. let kept = 0;
  261.  
  262. for (const file of files) {
  263. const filePath = path.join(tempDir, file);
  264.  
  265. try {
  266. const stats = await fs.stat(filePath);
  267.  
  268. const isUsed = usedFiles.has(path.normalize(filePath));
  269. const isOldEnough = maxAgeMs <= 0 ? true : ((now - stats.mtimeMs) > maxAgeMs);
  270.  
  271. // usuń jeśli nieużywany + stary (albo maxAgeMs=0)
  272. if (!isUsed && isOldEnough) {
  273. await fs.unlink(filePath);
  274. removed++;
  275. } else {
  276. kept++;
  277. }
  278. } catch (e) {
  279. self.logger.error('[Monochrome] Błąd przy sprawdzaniu/usuwaniu ' + file + ': ' + (e?.message || String(e)));
  280. }
  281. }
  282.  
  283. self.logger.info(`[Monochrome] DASH clean done: removed=${removed}, kept=${kept}, maxAgeMs=${maxAgeMs}`);
  284. })
  285. .catch((e) => {
  286. self.logger.error('[Monochrome] Błąd w _cleanUnusedDashFiles: ' + (e?.message || String(e)));
  287. });
  288. };
  289.  
  290.  
  291.  
  292.  
  293.  
  294.  
  295. ControllerMonochrome.prototype.onStop = function () {
  296. const self = this;
  297.  
  298. if (self.dashProxy) {
  299. self.dashProxy.kill();
  300. self.dashProxy = null;
  301. }
  302.  
  303. if (self._mpdMonitorInterval) {
  304. clearInterval(self._mpdMonitorInterval);
  305. self._mpdMonitorInterval = null;
  306. self.logger.info('[Monochrome] MPD monitor stopped');
  307. }
  308.  
  309. if (this.lowSpaceInterval) {
  310. clearInterval(this.lowSpaceInterval);
  311.  
  312. }
  313.  
  314.  
  315. if (this._idleQueueClearInterval) {
  316. clearInterval(this._idleQueueClearInterval);
  317. }
  318.  
  319.  
  320. if (this._dashCleanInterval) { // ‹ DODAJ TO
  321. clearInterval(this._dashCleanInterval);
  322. }
  323.  
  324. if (this._refreshInterval) {
  325. clearInterval(this._refreshInterval);
  326. }
  327.  
  328. try {
  329. self.commandRouter.volumioRemoveToBrowseSources('Monochrome');
  330. } catch (e) {}
  331.  
  332. // Wyczyść kolejkę podczas wyłączania wtyczki
  333. try {
  334. self.commandRouter.volumioClearQueue();
  335. self.logger.info('[Monochrome] Queue cleared on stop');
  336. } catch (e) {
  337. self.logger.warn('[Monochrome] Error clearing queue on stop: ' + e.message);
  338. }
  339.  
  340. self.logger.info('[Monochrome] Stopped');
  341. return libQ.resolve();
  342. };
  343.  
  344.  
  345.  
  346.  
  347. ControllerMonochrome.prototype.clearAddPlayTrack = function (track) {
  348. const self = this;
  349. const defer = libQ.defer();
  350.  
  351. self.logger.info('[Monochrome] clearAddPlayTrack: ' + (track && track.uri));
  352.  
  353. if (!track || !track.uri) {
  354. defer.reject(new Error('Invalid track'));
  355. return defer.promise;
  356. }
  357.  
  358. // Jeśli URI jest bezpośrednim URL-em (rehydratacja), NIE używaj mpc.
  359. if (track.uri.startsWith('http://') || track.uri.startsWith('https://')) {
  360. const item = {
  361. service: 'mpd',
  362. type: 'song',
  363. uri: track.uri,
  364. title: track.title || 'Monochrome Stream',
  365. name: track.name || track.title || 'Monochrome Stream',
  366. artist: track.artist || '',
  367. album: track.album || '',
  368. albumart: track.albumart,
  369. duration: track.duration || 0
  370. };
  371.  
  372. self.commandRouter.volumioClearAddPlayTrack(item)
  373. .then(() => defer.resolve())
  374. .catch((e) => {
  375. self.logger.error('[Monochrome] clearAddPlayTrack (http) error: ' + (e && e.message ? e.message : e));
  376. defer.reject(e);
  377. });
  378.  
  379. return defer.promise;
  380. }
  381.  
  382. // W przeciwnym razie: rehydratacja przez explodeUri i oddanie do Volumio
  383. self.explodeUri(track.uri)
  384. .then((tracks) => {
  385. if (!tracks || !Array.isArray(tracks) || tracks.length === 0) {
  386. throw new Error('No tracks returned from explodeUri');
  387. }
  388. return self.commandRouter.volumioClearAddPlayTrack(tracks[0]);
  389. })
  390. .then(() => defer.resolve())
  391. .catch((e) => {
  392. self.logger.error('[Monochrome] clearAddPlayTrack error: ' + (e && e.message ? e.message : e));
  393. defer.reject(e);
  394. });
  395.  
  396. return defer.promise;
  397. };
  398.  
  399.  
  400.  
  401. // ------------------------------------------------------------------
  402. // MPC – bezpośrednie sterowanie MPD
  403. // ------------------------------------------------------------------
  404.  
  405. ControllerMonochrome.prototype._mpc = function (cmd) {
  406. const self = this;
  407. const defer = libQ.defer();
  408.  
  409. exec(`mpc ${cmd}`, (err, stdout, stderr) => {
  410. if (err) {
  411. self.logger.error('[Monochrome] mpc error (' + cmd + '): ' + (stderr || err.message));
  412. return defer.reject(err);
  413. }
  414. defer.resolve((stdout || '').trim());
  415. });
  416.  
  417. return defer.promise;
  418. };
  419.  
  420. ControllerMonochrome.prototype.playViaMpcUrl = function (url, options) {
  421. const self = this;
  422. const defer = libQ.defer();
  423.  
  424. const safeUrl = String(url).replace(/'/g, "'\\''");
  425. const enqueueOnly = options && options.enqueueOnly;
  426.  
  427. let chain = libQ.resolve();
  428.  
  429. if (!enqueueOnly) {
  430. chain = chain
  431. .then(() => self._mpc('clear'))
  432. .then(() => self._mpc(`add '${safeUrl}'`))
  433. .then(() => self._mpc('play'));
  434. } else {
  435. chain = chain.then(() => self._mpc(`add '${safeUrl}'`));
  436. }
  437.  
  438. chain
  439. .then(() => {
  440. setTimeout(() => {
  441. try { self.commandRouter.executeOnPlugin('music_service', 'mpd', 'getState', ''); } catch (e) {}
  442. }, 250);
  443. defer.resolve();
  444. })
  445. .fail((e) => {
  446. self.logger.error('[Monochrome] playViaMpcUrl error: ' + (e && e.message ? e.message : e));
  447. defer.reject(e);
  448. });
  449.  
  450. return defer.promise;
  451. };
  452.  
  453. // ------------------------------------------------------------------
  454. // BROWSING & SEARCH UI
  455. // ------------------------------------------------------------------
  456.  
  457. ControllerMonochrome.prototype.addToBrowseSources = function () {
  458. const data = {
  459. name: 'Monochrome',
  460. uri: 'monochrome',
  461. plugin_type: 'music_service',
  462. plugin_name: 'monochrome',
  463. albumart: '/albumart?sourceicon=music_service/monochrome/assets/icon.png',
  464.  
  465. };
  466. this.commandRouter.volumioAddToBrowseSources(data);
  467. };
  468.  
  469. ControllerMonochrome.prototype.handleBrowseUri = function (curUri) {
  470. const self = this;
  471. const defer = libQ.defer();
  472. console.log('[Monochrome] handleBrowseUri: ' + curUri);
  473.  
  474. // Root
  475. if (curUri === 'monochrome') {
  476. defer.resolve({
  477. navigation: {
  478. lists: [{
  479. availableListViews: ['list'],
  480. type: 'title',
  481. title: 'Monochrome',
  482. items: [
  483. {
  484. service: 'monochrome',
  485. type: 'streaming-category',
  486. title: 'Search',
  487. icon: 'fa fa-search',
  488. uri: 'monochrome/search'
  489. },
  490. {
  491. service: 'monochrome',
  492. type: 'streaming-category',
  493. title: 'Favorite Playlists',
  494. icon: 'fa fa-heart',
  495. uri: 'monochrome/favorites/playlists'
  496. }
  497. ]
  498. }]
  499. }
  500. });
  501. return defer.promise;
  502. }
  503.  
  504. if (curUri.startsWith('monochrome/favorites/toggle/playlist/')) {
  505. const pid = decodeURIComponent(curUri.substring('monochrome/favorites/toggle/playlist/'.length));
  506. const isSpotify = pid.startsWith('spotify:');
  507.  
  508. if (isSpotify) {
  509. const favData = self.loadFavoritesData();
  510. const playlistId = pid.replace('spotify:', '');
  511. const imp = favData.spotifyImported && favData.spotifyImported[playlistId];
  512.  
  513. if (!imp) {
  514. self.commandRouter.pushToastMessage('error', 'Monochrome', 'Playlist not found');
  515. defer.resolve({ navigation: { lists: [] } });
  516. return defer.promise;
  517. }
  518.  
  519. const result = self.toggleFavoritePlaylist({
  520. id: pid,
  521. title: imp.title,
  522. uri: `monochrome_spotifypl:${playlistId}`,
  523. cover: imp.cover,
  524. type: 'spotify'
  525. });
  526.  
  527. self.commandRouter.pushToastMessage(
  528. 'success',
  529. 'Monochrome',
  530. result.added ? 'Added to favorites' : 'Removed from favorites'
  531. );
  532.  
  533. if (result.added) {
  534. // Playlista dodana – odśwież widok playlisty
  535. return self.handleBrowseUri(`monochrome_spotifypl:${playlistId}`)
  536. .then(response => defer.resolve(response))
  537. .catch(e => defer.reject(e));
  538. } else {
  539. // Playlista usunięta – wróć do widoku głównego (bez wywoływania handleBrowseUri)
  540. defer.resolve({
  541. navigation: {
  542. lists: [{
  543. availableListViews: ['list'],
  544. type: 'title',
  545. title: 'Monochrome',
  546. items: [
  547. {
  548. service: 'monochrome',
  549. type: 'streaming-category',
  550. title: 'Search',
  551. icon: 'fa fa-search',
  552. uri: 'monochrome/search'
  553. },
  554. {
  555. service: 'monochrome',
  556. type: 'streaming-category',
  557. title: 'Favorite Playlists',
  558. icon: 'fa fa-heart',
  559. uri: 'monochrome/favorites/playlists'
  560. }
  561. ]
  562. }]
  563. }
  564. });
  565. return defer.promise;
  566. }
  567.  
  568. }else {
  569. // Playlista Tidal – pobierz z API
  570. self.api.getPlaylist(pid)
  571. .then((res) => {
  572. const playlist = res.playlist || {};
  573. const result = self.toggleFavoritePlaylist({
  574. id: pid,
  575. title: playlist.title || playlist.name || 'Playlist',
  576. uri: `monochrome/playlist/${pid}`,
  577. cover: playlist.cover || null,
  578. type: 'tidal'
  579. });
  580.  
  581. self.commandRouter.pushToastMessage(
  582. 'success',
  583. 'Monochrome',
  584. result.added ? 'Added to favorites' : 'Removed from favorites'
  585. );
  586.  
  587. return self.handleBrowseUri(`monochrome/playlist/${encodeURIComponent(pid)}`);
  588. })
  589. .then(response => defer.resolve(response))
  590. .catch(e => {
  591. self.logger.error('[Monochrome] toggle favorite error: ' + e.message);
  592. defer.reject(e);
  593. });
  594. }
  595. return defer.promise;
  596. }
  597.  
  598. if (curUri.startsWith('monochrome_spotifypl:')) {
  599. const playlistId = curUri.substring('monochrome_spotifypl:'.length);
  600. const fav = self.loadFavoritesData();
  601. const imp = fav.spotifyImported && fav.spotifyImported[playlistId];
  602.  
  603. if (!imp) {
  604. defer.resolve({ navigation: { lists: [] } });
  605. return defer.promise;
  606. }
  607.  
  608. const pid = `spotify:${playlistId}`;
  609. const isFav = self.isFavoritePlaylist(pid);
  610.  
  611. const actionItem = {
  612. service: 'monochrome',
  613. type: 'folder',
  614. title: isFav ? 'Remove from favorites' : 'Add to favorites',
  615. icon: isFav ? 'fa fa-heart' : 'fa fa-heart-o',
  616. albumart: '/albumart?sourceicon=music_service/monochrome/icon.png',
  617. uri: `monochrome/favorites/toggle/playlist/${encodeURIComponent(pid)}`,
  618. // Dodajemy typ, aby przy zapisie wiedzieć, że to Spotify
  619. _type: 'spotify'
  620. };
  621.  
  622.  
  623. const autoRefreshItem = {
  624. service: 'monochrome',
  625. type: 'folder',
  626. title: imp.autoRefresh ? 'Disable auto-refresh (every 2 days)' : 'Enable auto-refresh (every 2 days)',
  627. icon: imp.autoRefresh ? 'fa fa-toggle-on' : 'fa fa-toggle-off',
  628. albumart: '/albumart?sourceicon=music_service/monochrome/icon.png',
  629. uri: `monochrome/spotify/toggleAutoRefresh/${playlistId}`
  630. };
  631.  
  632. const items = (imp.tracks || [])
  633. .filter(x => x && x.mapped && x.mapped.id)
  634. .map(x => ({
  635. service: 'monochrome',
  636. type: 'song',
  637. title: x.mapped.title || x.spotify.title || 'Unknown',
  638. artist: x.mapped.artist || (x.spotify.artists || []).join(', '),
  639. album: x.mapped.album || x.spotify.album || '',
  640. albumart: (() => {
  641. const coverId = x.mapped.cover || x.spotify.cover || imp.cover;
  642. return self.api.getCoverUrl ? self.api.getCoverUrl(coverId, 320) : coverId;
  643. })(),
  644. uri: 'monochrome://play/' + String(x.mapped.id),
  645. icon: 'fa fa-music'
  646. }));
  647.  
  648. const playlistCoverUrl = imp.cover ? (self.api.getCoverUrl ? self.api.getCoverUrl(imp.cover, 320) : imp.cover) : null;
  649.  
  650. defer.resolve({
  651. navigation: {
  652. prev: { uri: 'monochrome' },
  653. info: {
  654. uri: curUri,
  655. service: 'monochrome',
  656. albumart: playlistCoverUrl,
  657. title: imp.title || 'Imported Playlist',
  658. type: 'playlist',
  659. trackCount: items.length
  660. },
  661. lists: [
  662. {
  663. availableListViews: ['list'],
  664. title: imp.title || 'Imported Playlist',
  665. items: items
  666. },
  667. {
  668. availableListViews: ['list'],
  669. type: 'title',
  670. title: 'Options',
  671. items: [actionItem, autoRefreshItem] // teraz oba są folderami
  672. }
  673.  
  674.  
  675. ]
  676. }
  677. });
  678. return defer.promise;
  679. }
  680.  
  681.  
  682. if (curUri.startsWith('monochrome/spotify/toggleAutoRefresh/')) {
  683. const playlistId = decodeURIComponent(curUri.substring('monochrome/spotify/toggleAutoRefresh/'.length));
  684. const data = self.loadFavoritesData();
  685.  
  686. const playlistEntry = data.playlists.find(p => String(p.id) === `spotify:${playlistId}`);
  687. if (playlistEntry) {
  688. playlistEntry.autoRefresh = !playlistEntry.autoRefresh;
  689. if (data.spotifyImported && data.spotifyImported[playlistId]) {
  690. data.spotifyImported[playlistId].autoRefresh = playlistEntry.autoRefresh;
  691. }
  692. self.saveFavoritesData(data);
  693. }
  694.  
  695. // Pobierz świeże dane
  696. const updatedData = self.loadFavoritesData();
  697. const imp = updatedData.spotifyImported && updatedData.spotifyImported[playlistId];
  698. if (!imp) {
  699. defer.resolve({ navigation: { lists: [] } });
  700. return defer.promise;
  701. }
  702.  
  703. const pid = `spotify:${playlistId}`;
  704. const isFav = self.isFavoritePlaylist(pid);
  705. const autoRefresh = imp.autoRefresh || false;
  706.  
  707. const actionItem = {
  708. service: 'monochrome',
  709. type: 'folder',
  710. title: isFav ? 'Remove from favorites' : 'Add to favorites',
  711. icon: isFav ? 'fa fa-heart' : 'fa fa-heart-o',
  712. albumart: '/albumart?sourceicon=music_service/monochrome/icon.png',
  713. uri: `monochrome/favorites/toggle/playlist/${encodeURIComponent(pid)}`,
  714. _type: 'spotify'
  715. };
  716.  
  717. const autoRefreshItem = {
  718. service: 'monochrome',
  719. type: 'folder',
  720. title: autoRefresh ? 'Disable auto-refresh' : 'Enable auto-refresh',
  721. icon: autoRefresh ? 'fa fa-toggle-on' : 'fa fa-toggle-off',
  722. albumart: '/albumart?sourceicon=music_service/monochrome/icon.png',
  723. uri: `monochrome/spotify/toggleAutoRefresh/${playlistId}`
  724. };
  725.  
  726. const items = (imp.tracks || [])
  727. .filter(x => x && x.mapped && x.mapped.id)
  728. .map(x => ({
  729. service: 'monochrome',
  730. type: 'song',
  731. title: x.mapped.title || x.spotify.title || 'Unknown',
  732. artist: x.mapped.artist || (x.spotify.artists || []).join(', '),
  733. album: x.mapped.album || x.spotify.album || '',
  734. albumart: (() => {
  735. const coverId = x.mapped.cover || x.spotify.cover || imp.cover;
  736. return self.api.getCoverUrl ? self.api.getCoverUrl(coverId, 320) : coverId;
  737. })(),
  738. uri: 'monochrome://play/' + String(x.mapped.id),
  739. icon: 'fa fa-music'
  740. }));
  741.  
  742. const playlistCoverUrl = imp.cover ? (self.api.getCoverUrl ? self.api.getCoverUrl(imp.cover, 320) : imp.cover) : null;
  743.  
  744. defer.resolve({
  745. navigation: {
  746. prev: { uri: 'monochrome' },
  747. info: {
  748. uri: `monochrome_spotifypl:${playlistId}`,
  749. service: 'monochrome',
  750. albumart: playlistCoverUrl,
  751. title: imp.title || 'Imported Playlist',
  752. type: 'playlist',
  753. trackCount: items.length
  754. },
  755. lists: [
  756. {
  757. availableListViews: ['list'],
  758. title: imp.title || 'Imported Playlist',
  759. items: items
  760. },
  761. {
  762. availableListViews: ['list'],
  763. type: 'title',
  764. title: 'Options',
  765. items: [actionItem, autoRefreshItem]
  766. }
  767. ]
  768. }
  769. });
  770. return defer.promise;
  771. }
  772.  
  773.  
  774. // Search home
  775. if (curUri === 'monochrome/search') {
  776. defer.resolve({
  777. navigation: {
  778. prev: { uri: 'monochrome' },
  779. lists: [{
  780. availableListViews: ['list'],
  781. type: 'title',
  782. title: 'Search',
  783. items: [
  784. {
  785. service: 'monochrome',
  786. type: 'input',
  787. title: 'Tracks',
  788. icon: 'fa fa-music',
  789. placeholder: 'Enter track name...',
  790. uri: 'monochrome/search/tracks/'
  791. },
  792. {
  793. service: 'monochrome',
  794. type: 'input',
  795. title: 'Albums',
  796. icon: 'fa fa-folder',
  797. placeholder: 'Enter album name...',
  798. uri: 'monochrome/search/albums/'
  799. },
  800. {
  801. service: 'monochrome',
  802. type: 'input',
  803. title: 'Artists',
  804. icon: 'fa fa-user',
  805. placeholder: 'Enter artist name...',
  806. uri: 'monochrome/search/artists/'
  807. },
  808. {
  809. service: 'monochrome',
  810. type: 'input',
  811. title: 'Playlists',
  812. icon: 'fa fa-list',
  813. placeholder: 'Enter playlist name...',
  814. uri: 'monochrome/search/playlists/'
  815. }
  816. ]
  817. }]
  818. }
  819. });
  820. return defer.promise;
  821. }
  822.  
  823. // Tracks search results
  824. if (curUri.startsWith('monochrome/search/tracks/')) {
  825. const q = decodeURIComponent(curUri.substring('monochrome/search/tracks/'.length));
  826. self.searchTracks({ value: q })
  827. .then((lists) => {
  828. defer.resolve({ navigation: { prev: { uri: 'monochrome/search' }, lists } });
  829. })
  830. .catch((e) => {
  831. self.logger.error('[Monochrome] tracks browse error: ' + e.message);
  832. defer.resolve({ navigation: { lists: [] } });
  833. });
  834. return defer.promise;
  835. }
  836.  
  837. // Albums search results
  838. if (curUri.startsWith('monochrome/search/albums/')) {
  839. if (!self.config.get('enable_album_search')) {
  840. defer.resolve({ navigation: { prev: { uri: 'monochrome/search' }, lists: [] } });
  841. return defer.promise;
  842. }
  843. const q = decodeURIComponent(curUri.substring('monochrome/search/albums/'.length));
  844. self.searchAlbums({ value: q })
  845. .then((lists) => {
  846. defer.resolve({ navigation: { prev: { uri: 'monochrome/search' }, lists } });
  847. })
  848. .catch((e) => {
  849. self.logger.error('[Monochrome] albums browse error: ' + e.message);
  850. defer.resolve({ navigation: { lists: [] } });
  851. });
  852. return defer.promise;
  853. }
  854.  
  855. // Artist root
  856. if (curUri.startsWith('monochrome/artist/') &&
  857. !curUri.endsWith('/toptracks') &&
  858. !curUri.endsWith('/albums') &&
  859. !curUri.endsWith('/eps')) {
  860. const artistId = decodeURIComponent(curUri.substring('monochrome/artist/'.length));
  861.  
  862. // Pobierz limit z konfiguracji (lub ustaw domyślny 20)
  863. let limit = self.configObject?.search_limit;
  864. if (typeof limit !== 'number' || limit < 1) {
  865. limit = parseInt(self.config.get('search_limit'), 10);
  866. }
  867. if (isNaN(limit) || limit < 1) limit = 20;
  868.  
  869. const limitTracks = limit; // możesz zmienić, jeśli chcesz inny limit dla utworów
  870. const limitAlbums = limit; // analogicznie
  871.  
  872. const sortMode = self.configObject?.album_sort || self.config.get('album_sort') || 'newest';
  873.  
  874. Promise.resolve()
  875. .then(() => self.api.getArtistMetadata ? self.api.getArtistMetadata(artistId) : null)
  876. .then((data) => {
  877. const artist = (data && data.artist) ? data.artist : { name: `Artist ${artistId}` };
  878. const artistName = artist.name || `Artist ${artistId}`;
  879. const picture = artist.picture || artist.image || null;
  880.  
  881. // Równoległe pobieranie utworów i albumów
  882. return Promise.all([
  883. self.api.searchTracks(artistName, { limit: limitTracks * 2 }), // pobieramy więcej, bo będziemy filtrować
  884. self.api.searchAlbums(artistName, { limit: limitAlbums * 2 })
  885. ]).then(([tracksRes, albumsRes]) => {
  886. // Filtrujemy utwory – tylko te, które mają artystę pasującego do nazwy
  887. const allTracks = (tracksRes.items || []).filter(t => {
  888. const tArtist = (t.artist && t.artist.name) ? t.artist.name.toLowerCase() : '';
  889. return tArtist.includes(artistName.toLowerCase());
  890. });
  891. const tracks = allTracks.slice(0, limitTracks).map(t => ({
  892. service: 'monochrome',
  893. type: 'song',
  894. title: t.title || 'Unknown Track',
  895. artist: (t.artist && t.artist.name) ? t.artist.name : '',
  896. album: (t.album && t.album.title) ? t.album.title : '',
  897. albumart: self.api.getCoverUrl ? self.api.getCoverUrl(t.cover, '320') : undefined,
  898. uri: 'monochrome://play/' + t.id,
  899. duration: t.duration || 0
  900. }));
  901.  
  902. // Filtrujemy albumy – tylko te, których artysta pasuje
  903. let allAlbums = (albumsRes.items || []).filter(a => {
  904. const aArtist = (a.artist && a.artist.name) ? a.artist.name.toLowerCase() : '';
  905. return aArtist.includes(artistName.toLowerCase());
  906. });
  907.  
  908. // Sortowanie albumów
  909. if (allAlbums.length > 0) {
  910. allAlbums = allAlbums.map(item => {
  911. let timestamp = 0;
  912. if (item.releaseDate) {
  913. const d = new Date(item.releaseDate);
  914. timestamp = d.getTime() || 0;
  915. }
  916. return { ...item, _timestamp: timestamp };
  917. });
  918.  
  919. if (sortMode === 'newest') {
  920. allAlbums.sort((a, b) => b._timestamp - a._timestamp);
  921. } else if (sortMode === 'oldest') {
  922. allAlbums.sort((a, b) => a._timestamp - b._timestamp);
  923. } else if (sortMode === 'title') {
  924. allAlbums.sort((a, b) => {
  925. const titleA = (a.title || '').toLowerCase();
  926. const titleB = (b.title || '').toLowerCase();
  927. return titleA.localeCompare(titleB);
  928. });
  929. }
  930. }
  931.  
  932. // Mapowanie albumów z rokiem
  933. const albums = allAlbums.slice(0, limitAlbums).map(a => {
  934. let title = a.title || 'Unknown Album';
  935. if (a.releaseDate) {
  936. const year = new Date(a.releaseDate).getFullYear();
  937. if (!isNaN(year)) title += ` (${year})`;
  938. }
  939. return {
  940. service: 'monochrome',
  941. type: 'folder',
  942. title: title,
  943. artist: (a.artist && a.artist.name) ? a.artist.name : '',
  944. albumart: self.api.getCoverUrl ? self.api.getCoverUrl(a.cover, '320') : undefined,
  945. uri: a.id ? ('monochrome/album/' + a.id) : 'monochrome',
  946. };
  947. });
  948.  
  949. // Tworzymy listy
  950. const lists = [];
  951. if (tracks.length > 0) {
  952. lists.push({
  953. availableListViews: ['list'],
  954. type: 'title',
  955. title: 'Popular tracks',
  956. items: tracks
  957. });
  958. }
  959. if (albums.length > 0) {
  960. lists.push({
  961. availableListViews: ['list', 'grid'],
  962. type: 'title',
  963. title: 'Albums',
  964. items: albums
  965. });
  966. }
  967.  
  968. defer.resolve({
  969. navigation: {
  970. prev: { uri: 'monochrome/search' },
  971. info: {
  972. uri: curUri,
  973. service: 'monochrome',
  974. albumart: self.api.getCoverUrl ? self.api.getCoverUrl(picture, '320') : undefined,
  975. title: artistName,
  976. type: 'artist'
  977. },
  978. lists: lists
  979. }
  980. });
  981. });
  982. })
  983. .catch((e) => {
  984. self.logger.error('[Monochrome] artist root error: ' + e.message);
  985. defer.resolve({ navigation: { lists: [] } });
  986. });
  987. return defer.promise;
  988. }
  989.  
  990. if (curUri === 'monochrome/favorites/playlists') {
  991. const favs = self.loadFavoritePlaylists();
  992. const items = favs.map(p => {
  993. const isSpotify = p.id.startsWith('spotify:') || p.type === 'spotify';
  994. let uri;
  995. if (isSpotify) {
  996. const playlistId = p.id.replace('spotify:', '');
  997. uri = `monochrome_spotifypl:${playlistId}`;
  998. } else {
  999. uri = `monochrome/playlist/${p.id}`;
  1000. }
  1001. return {
  1002. service: 'monochrome',
  1003. type: 'folder',
  1004. title: p.title || 'Playlist',
  1005. albumart: p.cover ? (self.api.getCoverUrl ? self.api.getCoverUrl(p.cover, '320') : p.cover) : '/albumart?sourceicon=music_service/monochrome/icon.png',
  1006. icon: 'fa fa-list',
  1007. uri: uri
  1008. };
  1009. });
  1010.  
  1011. defer.resolve({
  1012. navigation: {
  1013. prev: { uri: 'monochrome' },
  1014. lists: [{
  1015. availableListViews: ['list'],
  1016. type: 'title',
  1017. title: 'Favorite Playlists',
  1018. items: items
  1019. }]
  1020. }
  1021. });
  1022. return defer.promise;
  1023. }
  1024.  
  1025.  
  1026.  
  1027.  
  1028.  
  1029.  
  1030.  
  1031.  
  1032.  
  1033. // Playlists search results
  1034. // Playlists search results
  1035. if (curUri.startsWith('monochrome/search/playlists/')) {
  1036. // Flaga z config.json: enable_playlist_search
  1037. const enabled =
  1038. (self.configObject && typeof self.configObject.enable_playlist_search !== 'undefined')
  1039. ? !!self.configObject.enable_playlist_search
  1040. : !!self.config.get('enable_playlist_search');
  1041.  
  1042. if (!enabled) {
  1043. defer.resolve({ navigation: { prev: { uri: 'monochrome/search' }, lists: [] } });
  1044. return defer.promise;
  1045. }
  1046.  
  1047. const q = decodeURIComponent(curUri.substring('monochrome/search/playlists/'.length));
  1048.  
  1049. self.searchPlaylists({ value: q })
  1050. .then((lists) => {
  1051. defer.resolve({ navigation: { prev: { uri: 'monochrome/search' }, lists } });
  1052. })
  1053. .catch((e) => {
  1054. self.logger.error('[Monochrome] playlists browse error: ' + (e && e.message ? e.message : e));
  1055. defer.resolve({ navigation: { prev: { uri: 'monochrome/search' }, lists: [] } });
  1056. });
  1057.  
  1058. return defer.promise;
  1059. }
  1060.  
  1061.  
  1062. // Album view
  1063. if (curUri.startsWith('monochrome/album/')) {
  1064. const albumId = decodeURIComponent(curUri.substring('monochrome/album/'.length));
  1065. console.log('[Monochrome] Opening album:', albumId);
  1066. self.api.getAlbum(albumId)
  1067. .then((res) => {
  1068. const album = res.album || {};
  1069. // Sortowanie utworów po numerze płyty i ścieżki
  1070. const tracks = (res.tracks || [])
  1071. .filter(Boolean)
  1072. .sort((a, b) => {
  1073. if (a.volumeNumber !== b.volumeNumber) return (a.volumeNumber || 1) - (b.volumeNumber || 1);
  1074. return (a.trackNumber || 0) - (b.trackNumber || 0);
  1075. })
  1076. .map(t => ({
  1077. service: 'monochrome',
  1078. type: 'song',
  1079. title: t.title || 'Unknown Track',
  1080. artist: (t.artist && t.artist.name) ? t.artist.name : '',
  1081. album: album.title || '',
  1082. albumart: self.api.getCoverUrl ? self.api.getCoverUrl(album.cover || t.cover, '320') : undefined,
  1083. uri: 'monochrome://play/' + (t.id || t.trackId || t.itemId),
  1084. icon: 'fa fa-music',
  1085. duration: t.duration || 0
  1086. }));
  1087.  
  1088. // Opcjonalnie dodaj info, ale bez type: 'album' (lub ustaw type: 'folder')
  1089. defer.resolve({
  1090. navigation: {
  1091. prev: { uri: 'monochrome/search' },
  1092. info: {
  1093. uri: `monochrome/album/${encodeURIComponent(albumId)}`, // zamiast curUri też OK, ale to jest jednoznaczne
  1094. service: 'monochrome',
  1095. albumart: self.api.getCoverUrl ? self.api.getCoverUrl(album.cover, '320') : undefined,
  1096. title: album.title || 'Unknown Album',
  1097. artist: (album.artist && album.artist.name) ? album.artist.name : '',
  1098. type: 'album', //album
  1099. year: album.releaseDate ? new Date(album.releaseDate).getFullYear() : undefined,
  1100. trackCount: tracks.length
  1101. },
  1102. lists: [{
  1103. availableListViews: ['list'],
  1104. type: 'title',
  1105. title: album.title || 'Album',
  1106. items: tracks
  1107. }]
  1108. }
  1109. });
  1110. })
  1111. .catch((e) => {
  1112. self.logger.error('[Monochrome] album browse error: ' + e.message);
  1113. defer.resolve({ navigation: { lists: [] } });
  1114. });
  1115. return defer.promise;
  1116. }
  1117.  
  1118. // Playlist view
  1119. if (curUri.startsWith('monochrome/playlist/')) {
  1120. const playlistId = decodeURIComponent(curUri.substring('monochrome/playlist/'.length));
  1121.  
  1122. self.api.getPlaylist(playlistId)
  1123. .then((res) => {
  1124. const playlist = res.playlist || {};
  1125. const pid = String(playlist.id || playlistId);
  1126.  
  1127. const isFav = self.isFavoritePlaylist(pid);
  1128.  
  1129. const actionItem = {
  1130. service: 'monochrome',
  1131. type: 'folder',
  1132. title: isFav ? 'Remove from favorites' : 'Add to favorites',
  1133. icon: isFav ? 'fa fa-heart' : 'fa fa-heart-o',
  1134. albumart: '/albumart?sourceicon=music_service/monochrome/icon.png',
  1135. uri: `monochrome/favorites/toggle/playlist/${encodeURIComponent(pid)}`
  1136. };
  1137.  
  1138. const tracks = (res.tracks || [])
  1139. .filter(Boolean)
  1140. .map(t => {
  1141. const id = (t && (t.id || t.trackId || t.itemId)) ? String(t.id || t.trackId || t.itemId) : null;
  1142.  
  1143. return {
  1144. service: 'monochrome',
  1145. type: 'song',
  1146. title: (t && t.title) ? t.title : 'Unknown Track',
  1147. artist: (t && t.artist && t.artist.name) ? t.artist.name : '',
  1148. album: (t && t.album && t.album.title) ? t.album.title : '',
  1149. albumart: self.api.getCoverUrl ? self.api.getCoverUrl(t.cover, '320') : undefined,
  1150. // format docelowy:
  1151. uri: id ? ('monochrome://play/' + encodeURIComponent(id)) : 'monochrome',
  1152. icon: 'fa fa-music',
  1153. duration: (t && t.duration) ? t.duration : 0
  1154. };
  1155. })
  1156. // usuń rekordy bez poprawnego id (żeby nie było "monochrome" jako track)
  1157. .filter(it => it.uri && it.uri.startsWith('monochrome://play/'));
  1158.  
  1159. const coverUrl = self.api.getCoverUrl ? self.api.getCoverUrl(playlist.cover, '320') : undefined;
  1160. const playlistTitle = playlist.title || playlist.name || 'Playlist';
  1161.  
  1162. defer.resolve({
  1163. navigation: {
  1164. prev: { uri: 'monochrome/search' },
  1165. info: {
  1166. uri: curUri,
  1167. service: 'monochrome',
  1168. albumart: coverUrl,
  1169. title: playlistTitle,
  1170. type: 'playlist',
  1171. trackCount: tracks.length
  1172. },
  1173. lists: [
  1174. {
  1175. availableListViews: ['list'],
  1176. type: 'title',
  1177. title: 'Tracks',
  1178. items: tracks
  1179. },
  1180. {
  1181. availableListViews: ['list'],
  1182. type: 'title',
  1183. title: 'Options',
  1184. items: [actionItem]
  1185. },
  1186. ]
  1187. }
  1188. });
  1189. })
  1190. .catch((e) => {
  1191. self.logger.error('[Monochrome] playlist browse error: ' + e.message);
  1192. defer.resolve({ navigation: { lists: [] } });
  1193. });
  1194.  
  1195. return defer.promise;
  1196. }
  1197.  
  1198.  
  1199.  
  1200.  
  1201. // Artist root
  1202. if (curUri.startsWith('monochrome/artist/') &&
  1203. !curUri.endsWith('/toptracks') &&
  1204. !curUri.endsWith('/albums') &&
  1205. !curUri.endsWith('/eps')) {
  1206. const artistId = decodeURIComponent(curUri.substring('monochrome/artist/'.length));
  1207. Promise.resolve()
  1208. .then(() => self.api.getArtistMetadata ? self.api.getArtistMetadata(artistId) : null)
  1209. .then((data) => {
  1210. const artist = (data && data.artist) ? data.artist : { name: `Artist ${artistId}` };
  1211. const name = artist.name || `Artist ${artistId}`;
  1212. defer.resolve({
  1213. navigation: {
  1214. prev: { uri: 'monochrome/search' },
  1215. lists: [{
  1216. availableListViews: ['list'],
  1217. type: 'title',
  1218. title: name,
  1219. items: [
  1220. { service: 'monochrome', type: 'streaming-category', title: 'Popular tracks', icon: 'fa fa-fire', uri: `monochrome/artist/${encodeURIComponent(artistId)}/toptracks` },
  1221. { service: 'monochrome', type: 'streaming-category', title: 'Albums', icon: 'fa fa-folder', uri: `monochrome/artist/${encodeURIComponent(artistId)}/albums` },
  1222. { service: 'monochrome', type: 'streaming-category', title: 'EPs & Singles', icon: 'fa fa-dot-circle-o', uri: `monochrome/artist/${encodeURIComponent(artistId)}/eps` }
  1223. ]
  1224. }]
  1225. }
  1226. });
  1227. })
  1228. .catch(() => {
  1229. defer.resolve({
  1230. navigation: {
  1231. prev: { uri: 'monochrome/search' },
  1232. lists: [{
  1233. availableListViews: ['list'],
  1234. type: 'title',
  1235. title: `Artist ${artistId}`,
  1236. items: [
  1237. { service: 'monochrome', type: 'streaming-category', title: 'Popular tracks', icon: 'fa fa-fire', uri: `monochrome/artist/${encodeURIComponent(artistId)}/toptracks` },
  1238. { service: 'monochrome', type: 'streaming-category', title: 'Albums', icon: 'fa fa-folder', uri: `monochrome/artist/${encodeURIComponent(artistId)}/albums` },
  1239. { service: 'monochrome', type: 'streaming-category', title: 'EPs & Singles', icon: 'fa fa-dot-circle-o', uri: `monochrome/artist/${encodeURIComponent(artistId)}/eps` }
  1240. ]
  1241. }]
  1242. }
  1243. });
  1244. });
  1245. return defer.promise;
  1246. }
  1247.  
  1248. // Artist popular tracks
  1249. if (curUri.startsWith('monochrome/artist/') && curUri.endsWith('/toptracks')) {
  1250. const artistId = decodeURIComponent(curUri.substring('monochrome/artist/'.length, curUri.lastIndexOf('/toptracks')));
  1251. const limit = parseInt(self.config.get('search_limit') || 20, 10);
  1252. Promise.resolve()
  1253. .then(() => self.api.getArtistMetadata ? self.api.getArtistMetadata(artistId) : null)
  1254. .then((data) => {
  1255. const artist = (data && data.artist) ? data.artist : { name: '' };
  1256. const artistName = artist.name || '';
  1257. if (!artistName) throw new Error('Missing artist name');
  1258. return Promise.all([artistName, self.api.searchTracks(artistName)]);
  1259. })
  1260. .then(([artistName, res]) => {
  1261. const items = (res && Array.isArray(res.items)) ? res.items : [];
  1262. const tracks = items
  1263. .filter(t => t.artist && t.artist.name && t.artist.name.toLowerCase().includes(artistName.toLowerCase()))
  1264. .slice(0, limit)
  1265. .map(t => ({
  1266. service: 'monochrome',
  1267. type: 'song',
  1268. title: t.title || 'Unknown Track',
  1269. artist: t.artist.name || '',
  1270. album: (t.album && t.album.title) || '',
  1271. albumart: self.api.getCoverUrl ? self.api.getCoverUrl(t.cover, '320') : undefined,
  1272. uri: 'monochrome://play/' + t.id,
  1273. icon: 'fa fa-music',
  1274. duration: t.duration || 0
  1275. }));
  1276. defer.resolve({
  1277. navigation: {
  1278. prev: { uri: 'monochrome/artist/' + encodeURIComponent(artistId) },
  1279. lists: [{
  1280. availableListViews: ['list'],
  1281. type: 'title',
  1282. title: 'Popular tracks',
  1283. items: tracks
  1284. }]
  1285. }
  1286. });
  1287. })
  1288. .catch((e) => {
  1289. self.logger.error('[Monochrome] artist toptracks error: ' + e.message);
  1290. defer.resolve({ navigation: { lists: [] } });
  1291. });
  1292. return defer.promise;
  1293. }
  1294.  
  1295. // Artist albums / EPs
  1296. if (curUri.startsWith('monochrome/artist/') && (curUri.endsWith('/albums') || curUri.endsWith('/eps'))) {
  1297. const isEps = curUri.endsWith('/eps');
  1298. const suffix = isEps ? '/eps' : '/albums';
  1299. const artistId = decodeURIComponent(curUri.substring('monochrome/artist/'.length, curUri.lastIndexOf(suffix)));
  1300. const limit = parseInt(self.config.get('search_limit') || 20, 10);
  1301. const epWords = [' ep', '(ep', 'single', 'singles'];
  1302.  
  1303. Promise.resolve()
  1304. .then(() => self.api.getArtistMetadata ? self.api.getArtistMetadata(artistId) : null)
  1305. .then((data) => {
  1306. const artist = (data && data.artist) ? data.artist : { name: '' };
  1307. const artistName = artist.name || '';
  1308. if (!artistName) throw new Error('Missing artist name');
  1309. return Promise.all([artistName, self.api.searchAlbums(artistName)]);
  1310. })
  1311. .then(([artistName, res]) => {
  1312. let items = (res && Array.isArray(res.items)) ? res.items : [];
  1313.  
  1314. // ---------- SORTOWANIE ALBUMÓW W WIDOKU ARTYSTY ----------
  1315. const sortMode = self.configObject?.album_sort || self.config.get('album_sort') || 'newest';
  1316.  
  1317. if (items.length > 0) {
  1318. items = items.map(item => {
  1319. let timestamp = 0;
  1320. if (item.releaseDate) {
  1321. const d = new Date(item.releaseDate);
  1322. timestamp = d.getTime() || 0;
  1323. }
  1324. return { ...item, _timestamp: timestamp };
  1325. });
  1326.  
  1327. if (sortMode === 'newest') {
  1328. items.sort((a, b) => b._timestamp - a._timestamp);
  1329. } else if (sortMode === 'oldest') {
  1330. items.sort((a, b) => a._timestamp - b._timestamp);
  1331. } else if (sortMode === 'title') {
  1332. items.sort((a, b) => {
  1333. const titleA = (a.title || '').toLowerCase();
  1334. const titleB = (b.title || '').toLowerCase();
  1335. return titleA.localeCompare(titleB);
  1336. });
  1337. }
  1338. }
  1339.  
  1340. if (isEps) {
  1341. items = items.filter(a => {
  1342. const title = String(a.title || '').toLowerCase();
  1343. return epWords.some(w => title.includes(w));
  1344. });
  1345. }
  1346. const albums = items.slice(0, limit).map(a => {
  1347. const id = a.id || a.albumId || a.itemId;
  1348.  
  1349. // ---------- DODANIE ROKU DO TYTUŁU ----------
  1350. let title = a.title || (isEps ? 'EP / Single' : 'Album');
  1351. if (a.releaseDate) {
  1352. const year = new Date(a.releaseDate).getFullYear();
  1353. if (!isNaN(year)) title += ` (${year})`;
  1354. }
  1355.  
  1356.  
  1357. return {
  1358. service: 'monochrome',
  1359. type: 'folder',
  1360. title: title,
  1361. artist: (a.artist && a.artist.name) ? a.artist.name : artistName,
  1362. albumart: self.api.getCoverUrl ? self.api.getCoverUrl(a.cover, '320') : undefined,
  1363. uri: id ? ('monochrome/album/' + id) : 'monochrome',
  1364. icon: isEps ? 'fa fa-dot-circle-o' : 'fa fa-folder'
  1365. };
  1366. });
  1367. defer.resolve({
  1368. navigation: {
  1369. prev: { uri: 'monochrome/artist/' + encodeURIComponent(artistId) },
  1370. lists: [{
  1371. availableListViews: ['list'],
  1372. type: 'title',
  1373. title: isEps ? 'EPs & Singles' : 'Albums',
  1374. items: albums
  1375. }]
  1376. }
  1377. });
  1378. })
  1379. .catch((e) => {
  1380. self.logger.error('[Monochrome] artist albums/eps error: ' + e.message);
  1381. defer.resolve({ navigation: { lists: [] } });
  1382. });
  1383. return defer.promise;
  1384. }
  1385.  
  1386. defer.resolve({ navigation: { lists: [] } });
  1387. return defer.promise;
  1388. };
  1389.  
  1390. // ------------------------------------------------------------------
  1391. // SEARCH METHODS – Native Promises (async/await)
  1392. // ------------------------------------------------------------------
  1393.  
  1394. ControllerMonochrome.prototype.searchTracks = async function (query) {
  1395. const self = this;
  1396. const term = (query && (query.value || query.term)) ? String(query.value || query.term).trim() : '';
  1397. if (!term || term.length < 2) return [];
  1398.  
  1399. let limit = parseInt(self.config.get('search_limit'), 10);
  1400. if (isNaN(limit) || limit < 1) limit = 20;
  1401.  
  1402. try {
  1403. const tracksRes = await self.api.searchTracks(term, { limit });
  1404. const tracks = (tracksRes.items || []).slice(0, limit).filter(Boolean).map(t => ({
  1405. service: 'monochrome',
  1406. type: 'song',
  1407. title: t.title || 'Unknown Track',
  1408. name: t.title || 'Unknown Track',
  1409. artist: (t.artist && t.artist.name) ? t.artist.name : (typeof t.artist === 'string' ? t.artist : 'Unknown Artist'),
  1410. album: (t.album && t.album.title) ? t.album.title : (typeof t.album === 'string' ? t.album : ''),
  1411. albumart: self.api.getCoverUrl ? self.api.getCoverUrl(t.cover, '320') : undefined,
  1412. uri: 'monochrome://play/' + String(t.id),
  1413. icon: 'fa fa-music',
  1414. duration: t.duration || 0
  1415. }));
  1416. console.log('[Monochrome] searchTracks items:', tracksRes.items.map(t => ({ id: t.id, title: t.title })));
  1417. const lists = [];
  1418. if (tracks.length) lists.push({ title: 'Tracks', availableListViews: ['list'], items: tracks });
  1419. return lists;
  1420. } catch (error) {
  1421. self.logger.error('[Monochrome] searchTracks error: ' + error.message);
  1422. return [];
  1423. }
  1424. };
  1425.  
  1426. ControllerMonochrome.prototype.searchAlbums = async function (query) {
  1427. const self = this;
  1428. const term = (query && (query.value || query.term)) ? String(query.value || query.term).trim() : '';
  1429. if (!term || term.length < 2) return [];
  1430.  
  1431. // ---------- POBIERANIE LIMITU ----------
  1432. let limit = self.configObject?.search_limit;
  1433. if (typeof limit !== 'number' || limit < 1) {
  1434. limit = parseInt(self.config.get('search_limit'), 10);
  1435. }
  1436. if (isNaN(limit) || limit < 1) limit = 20;
  1437.  
  1438. // ---------- POBIERANIE USTAWIENIA SORTOWANIA ----------
  1439. const sortMode = self.configObject?.album_sort || self.config.get('album_sort') || 'newest';
  1440.  
  1441. console.log(`[Monochrome] ?? searchAlbums: term="${term}", limit=${limit}, sort=${sortMode}`);
  1442.  
  1443. try {
  1444. const result = await self.api.searchAlbums(term, { limit });
  1445. let items = (result && Array.isArray(result.items)) ? result.items : [];
  1446. console.log(`[Monochrome] ?? searchAlbums: raw items = ${items.length}`);
  1447.  
  1448. // ---------- SORTOWANIE ----------
  1449. if (items.length > 0) {
  1450. // Najpierw konwertujemy releaseDate na timestamp dla wydajności
  1451. items = items.map(item => {
  1452. let timestamp = 0;
  1453. if (item.releaseDate) {
  1454. const d = new Date(item.releaseDate);
  1455. timestamp = d.getTime() || 0; // jeśli nieprawidłowa data -> 0
  1456. }
  1457. return { ...item, _timestamp: timestamp };
  1458. });
  1459.  
  1460. // Sortowanie w zależności od wybranej opcji
  1461. if (sortMode === 'newest') {
  1462. items.sort((a, b) => b._timestamp - a._timestamp); // najnowsze pierwsze
  1463. } else if (sortMode === 'oldest') {
  1464. items.sort((a, b) => a._timestamp - b._timestamp); // najstarsze pierwsze
  1465. } else if (sortMode === 'title') {
  1466. items.sort((a, b) => {
  1467. const titleA = (a.title || '').toLowerCase();
  1468. const titleB = (b.title || '').toLowerCase();
  1469. return titleA.localeCompare(titleB);
  1470. });
  1471. }
  1472. }
  1473.  
  1474. // ---------- MAPOWANIE NA FORMAT VOLUMIO ----------
  1475. const albums = items.slice(0, limit).filter(Boolean).map(a => {
  1476. let title = a.title || 'Unknown Album';
  1477. if (a.releaseDate) {
  1478. const year = new Date(a.releaseDate).getFullYear();
  1479. if (!isNaN(year)) title += ` (${year})`;
  1480. }
  1481.  
  1482. return {
  1483. service: 'monochrome',
  1484. type: 'folder',
  1485. title: title,
  1486. artist: (a.artist && a.artist.name) ? a.artist.name : (typeof a.artist === 'string' ? a.artist : ''),
  1487. albumart: (() => {
  1488. const url = self.api.getCoverUrl ? self.api.getCoverUrl(a.cover, '320') : null;
  1489. return url || '/albumart?sourceicon=music_service/monochrome/icon.png';
  1490. })(),
  1491. uri: (a.id || a.albumId || a.itemId) ? ('monochrome/album/' + (a.id || a.albumId || a.itemId)) : 'monochrome',
  1492. icon: 'fa fa-folder'
  1493. };
  1494. });
  1495.  
  1496. console.log(`[Monochrome] ?? searchAlbums: returning ${albums.length} albums (sorted: ${sortMode})`);
  1497. return [{ title: 'Albums', availableListViews: ['list'], items: albums }];
  1498. } catch (error) {
  1499. self.logger.error('[Monochrome] searchAlbums error: ' + error.message);
  1500. return [];
  1501. }
  1502. };
  1503.  
  1504.  
  1505. ControllerMonochrome.prototype.searchArtists = async function (query) {
  1506. const self = this;
  1507. const term = (query && (query.value || query.term)) ? String(query.value || query.term).trim() : '';
  1508. if (!term || term.length < 2) return [];
  1509.  
  1510. let limit = self.configObject?.search_limit;
  1511. if (typeof limit !== 'number' || limit < 1) {
  1512. limit = parseInt(self.config.get('search_limit'), 10);
  1513. }
  1514. if (isNaN(limit) || limit < 1) limit = 20;
  1515.  
  1516. console.log(`[Monochrome] searchArtists: term="${term}", limit=${limit}`);
  1517.  
  1518. try {
  1519. const result = await self.api.searchArtists(term, { limit });
  1520. const items = (result && Array.isArray(result.items)) ? result.items : [];
  1521. console.log(`[Monochrome] searchArtists: raw items = ${items.length}`);
  1522.  
  1523. const artists = items.slice(0, limit).filter(Boolean).map(a => {
  1524. const id = a.id || a.artistId || a.itemId;
  1525. return {
  1526. service: 'monochrome',
  1527. type: 'folder',
  1528. title: a.name || 'Unknown Artist',
  1529. albumart: (() => {
  1530. const coverId = a.picture || a.cover || null;
  1531. console.log(`[Monochrome] Artist "${a.name}" coverId:`, coverId);
  1532. const url = self.api.getCoverUrl ? self.api.getCoverUrl(coverId, '320') : null;
  1533. return url || '/albumart?sourceicon=music_service/monochrome/icon.png';
  1534. })(),
  1535. uri: id ? ('monochrome/artist/' + id) : 'monochrome',
  1536. icon: 'fa fa-user'
  1537. };
  1538. });
  1539.  
  1540. console.log(`[Monochrome] searchArtists: returning ${artists.length} artists`);
  1541. return [{ title: 'Artists', availableListViews: ['list'], items: artists }];
  1542. } catch (error) {
  1543. self.logger.error('[Monochrome] searchArtists error: ' + error.message);
  1544. return [];
  1545. }
  1546. };
  1547.  
  1548. ControllerMonochrome.prototype.searchPlaylists = async function (query) {
  1549. const self = this;
  1550. const term = (query && (query.value || query.term)) ? String(query.value || query.term).trim() : '';
  1551. console.log('[Monochrome] searchPlaylists called with term:', term); // ‹ log na początku
  1552.  
  1553. if (!term || term.length < 2) {
  1554. console.log('[Monochrome] searchPlaylists: term too short, returning []');
  1555. return [];
  1556. }
  1557.  
  1558. // Pobranie limitu z konfiguracji
  1559. let limit = self.configObject?.search_limit;
  1560. if (typeof limit !== 'number' || limit < 1) {
  1561. limit = parseInt(self.config.get('search_limit'), 10);
  1562. }
  1563. if (isNaN(limit) || limit < 1) limit = 20;
  1564. console.log('[Monochrome] searchPlaylists limit:', limit);
  1565.  
  1566. try {
  1567. const result = await self.api.searchPlaylists(term, { limit });
  1568. console.log('[Monochrome] searchPlaylists result from API:', result); // log całego wyniku
  1569.  
  1570. const items = (result && Array.isArray(result.items)) ? result.items : [];
  1571. console.log('[Monochrome] searchPlaylists items count:', items.length);
  1572.  
  1573. if (items.length > 0) {
  1574. console.log('[Monochrome] First playlist item keys:', Object.keys(items[0]));
  1575. }
  1576.  
  1577. const playlists = items
  1578. .slice(0, limit)
  1579. .filter(Boolean)
  1580. .map(p => {
  1581. const pid = p.id != null ? String(p.id) : null;
  1582. if (!pid) return null;
  1583.  
  1584. return {
  1585. service: 'monochrome',
  1586. type: 'folder',
  1587. title: p.title || p.name || 'Playlist',
  1588. albumart: self.api.getCoverUrl ? self.api.getCoverUrl(p.cover || p.image, 320) : undefined,
  1589. uri: `monochrome/playlist/${encodeURIComponent(pid)}`,
  1590. icon: 'fa fa-list'
  1591. };
  1592. })
  1593. .filter(Boolean);
  1594.  
  1595. console.log('[Monochrome] searchPlaylists returning', playlists.length, 'items');
  1596. return [{ title: 'Playlists', availableListViews: ['list'], items: playlists }];
  1597. } catch (error) {
  1598. self.logger.error('[Monochrome] searchPlaylists error: ' + error.message);
  1599. return [];
  1600. }
  1601. };
  1602.  
  1603.  
  1604.  
  1605. // ------------------------------------------------------------------
  1606. // GLOBAL SEARCH – aggregator (native Promise)
  1607. // ------------------------------------------------------------------
  1608.  
  1609. ControllerMonochrome.prototype.search = function (query) {
  1610. const self = this;
  1611. const defer = libQ.defer();
  1612.  
  1613. const term = (query && (query.value || query.term)) ? String(query.value || query.term).trim() : '';
  1614. if (!term || term.length < 2) {
  1615. defer.resolve([]);
  1616. return defer.promise;
  1617. }
  1618.  
  1619. const promises = [];
  1620.  
  1621. // Tracks – zawsze włączone
  1622. promises.push(self.searchTracks({ value: term }).catch(() => []));
  1623.  
  1624. // Albums – jeśli włączone w konfiguracji
  1625. if (self.configObject?.enable_album_search) {
  1626. promises.push(self.searchAlbums({ value: term }).catch(() => []));
  1627. }
  1628.  
  1629. // Artists – jeśli włączone
  1630. if (self.configObject?.enable_artist_search) {
  1631. promises.push(self.searchArtists({ value: term }).catch(() => []));
  1632. }
  1633.  
  1634. // Playlists – jeśli włączone
  1635. if (self.configObject?.enable_playlist_search) {
  1636. promises.push(self.searchPlaylists({ value: term }).catch(() => []));
  1637. }
  1638.  
  1639. Promise.all(promises)
  1640. .then(results => {
  1641. const allLists = results.flat().filter(Boolean);
  1642. console.log(`[Monochrome] ?? Global search collected ${allLists.length} result sections`);
  1643. defer.resolve(allLists);
  1644. })
  1645. .catch(err => {
  1646. self.logger.error('[Monochrome] Global search failed:', err.message);
  1647. defer.resolve([]); // zwracamy pustą tablicę, żeby nie rozwalać UI
  1648. });
  1649.  
  1650. return defer.promise;
  1651. };
  1652.  
  1653.  
  1654. // ------------------------------------------------------------------
  1655. // EXPLODE URI – odtwarzanie
  1656. // ------------------------------------------------------------------
  1657.  
  1658. ControllerMonochrome.prototype.explodeUri = function (uri) {
  1659. const self = this;
  1660. const defer = libQ.defer();
  1661.  
  1662. self.logger.info('[Monochrome] explodeUri ' + uri);
  1663.  
  1664. if (!uri || typeof uri !== 'string') {
  1665. defer.reject(new Error('Invalid URI'));
  1666. return defer.promise;
  1667. }
  1668.  
  1669. // Rehydratacja: jeżeli już jest URL, zwróć jako track dla MPD (bez odtwarzania!)
  1670. if (uri.startsWith('http://') || uri.startsWith('https://')) {
  1671. defer.resolve([{
  1672. service: 'mpd',
  1673. type: 'song',
  1674. uri: uri,
  1675. title: 'Monochrome Stream',
  1676. name: 'Monochrome Stream'
  1677. }]);
  1678. return defer.promise;
  1679. }
  1680.  
  1681.  
  1682.  
  1683. // Obsługa playlisty Spotify (importowanej)
  1684. if (uri.startsWith('monochrome_spotifypl:')) {
  1685. const playlistId = uri.substring('monochrome_spotifypl:'.length);
  1686. const fav = self.loadFavoritesData();
  1687. const imp = fav.spotifyImported && fav.spotifyImported[playlistId];
  1688.  
  1689. if (!imp) {
  1690. defer.reject(new Error('Spotify playlist not found'));
  1691. return defer.promise;
  1692. }
  1693.  
  1694. const trackPromises = (imp.tracks || [])
  1695. .filter(x => x && x.mapped && x.mapped.id)
  1696. .map(x =>
  1697. self.api.getTrackStream(x.mapped.id, 'LOSSLESS')
  1698. .then(streamInfo => ({
  1699. service: 'mpd',
  1700. type: 'song',
  1701. uri: streamInfo.url,
  1702. title: x.mapped.title || x.spotify.title || 'Unknown Track',
  1703. name: x.mapped.title || x.spotify.title || 'Unknown Track',
  1704. artist: x.mapped.artist || (x.spotify.artists || []).join(', '),
  1705. album: x.mapped.album || x.spotify.album || '',
  1706. albumart: (() => {
  1707. const coverId = x.mapped.cover || x.spotify.cover || imp.cover;
  1708. return self.api.getCoverUrl ? self.api.getCoverUrl(coverId, 1280) : coverId;
  1709. })(),
  1710. duration: x.mapped.duration || 0,
  1711. trackType: streamInfo._sourceName,
  1712. codec: streamInfo.trackType,
  1713. icon: streamInfo._sourceIcon || '/albumart?sourceicon=music_service/monochrome/icon.png'
  1714. }))
  1715. .catch(err => {
  1716. self.logger.error(`[Monochrome] Skipping Spotify track ${x.mapped.id} due to error: ${err.message}`);
  1717. return null; // pomijamy ten utwór
  1718. })
  1719. );
  1720.  
  1721. Promise.allSettled(trackPromises)
  1722. .then(results => {
  1723. const tracks = results
  1724. .filter(r => r.status === 'fulfilled' && r.value !== null)
  1725. .map(r => r.value);
  1726.  
  1727. if (tracks.length === 0) {
  1728. self.commandRouter.pushToastMessage('warning', 'Monochrome', 'No playable tracks in this playlist');
  1729. defer.resolve([]); // pusta kolejka – nie przerywa działania
  1730. } else {
  1731. defer.resolve(tracks);
  1732. }
  1733. })
  1734. .catch(e => {
  1735. self.logger.error('[Monochrome] explodeUri Spotify playlist error: ' + e.message);
  1736. defer.reject(e);
  1737. });
  1738.  
  1739. return defer.promise;
  1740. }
  1741.  
  1742.  
  1743.  
  1744. // ----- NOWE: Obsługa całej playlisty -----
  1745. if (uri.startsWith('monochrome://playlist/') || uri.startsWith('monochrome/playlist/')) {
  1746. let playlistId = uri.replace(/^monochrome:\/\/playlist\//, '').replace(/^monochrome\/playlist\//, '');
  1747. try { playlistId = decodeURIComponent(playlistId); } catch (e) {}
  1748.  
  1749. self.api.getPlaylist(playlistId)
  1750. .then((res) => {
  1751. const trackPromises = (res.tracks || [])
  1752. .filter(Boolean)
  1753. .map(t =>
  1754. self.api.getTrackStream(t.id || t.trackId || t.itemId, 'LOSSLESS')
  1755. .then(streamInfo => ({
  1756. service: 'mpd',
  1757. type: 'song',
  1758. uri: streamInfo.url,
  1759. title: t.title || 'Unknown Track',
  1760. name: t.title || 'Unknown Track',
  1761. artist: (t.artist && t.artist.name) ? t.artist.name : '',
  1762. album: (t.album && t.album.title) ? t.album.title : '',
  1763. albumart: self.api.getCoverUrl ? self.api.getCoverUrl(t.cover, '1280') : undefined,
  1764. duration: t.duration || 0,
  1765. trackType: streamInfo._sourceName,
  1766. codec: streamInfo.trackType,
  1767. icon: streamInfo._sourceIcon || '/albumart?sourceicon=music_service/monochrome/icon.png'
  1768. }))
  1769. .catch(err => {
  1770. self.logger.error(`[Monochrome] Skipping track ${t.id} due to error: ${err.message}`);
  1771. return null; // pomijamy ten utwór
  1772. })
  1773. );
  1774.  
  1775. Promise.allSettled(trackPromises)
  1776. .then(results => {
  1777. const tracks = results
  1778. .filter(r => r.status === 'fulfilled' && r.value !== null)
  1779. .map(r => r.value);
  1780.  
  1781. if (tracks.length === 0) {
  1782. defer.reject(new Error('No playable tracks in this playlist'));
  1783. } else {
  1784. defer.resolve(tracks);
  1785. }
  1786. })
  1787. .catch(e => defer.reject(e));
  1788. })
  1789. .catch(e => {
  1790. self.logger.error('[Monochrome] explodeUri playlist error: ' + e.message);
  1791. defer.reject(e);
  1792. });
  1793. return defer.promise;
  1794. }
  1795.  
  1796. // ----- NOWE: Obsługa całego albumu -----
  1797. if (uri.startsWith('monochrome://album/') || uri.startsWith('monochrome/album/')) {
  1798. let albumId = uri.replace(/^monochrome:\/\/album\//, '').replace(/^monochrome\/album\//, '');
  1799. try { albumId = decodeURIComponent(albumId); } catch (e) {}
  1800.  
  1801. self.api.getAlbum(albumId)
  1802. .then((res) => {
  1803. const promises = (res.tracks || [])
  1804. .filter(Boolean)
  1805. .map(t =>
  1806. self.api.getTrackStream(t.id || t.trackId || t.itemId, 'LOSSLESS')
  1807. .then(streamInfo => ({
  1808. service: 'mpd',
  1809. type: 'song',
  1810. uri: streamInfo.url,
  1811. title: t.title || 'Unknown Track',
  1812. name: t.title || 'Unknown Track',
  1813. artist: (t.artist && t.artist.name) ? t.artist.name : '',
  1814. album: (t.album && t.album.title) ? t.album.title : '',
  1815. albumart: self.api.getCoverUrl ? self.api.getCoverUrl(t.cover, '1280') : undefined,
  1816. duration: t.duration || 0,
  1817. trackType: streamInfo._sourceName,
  1818. codec: streamInfo.trackType,
  1819. icon: streamInfo._sourceIcon || '/albumart?sourceicon=music_service/monochrome/icon.png'
  1820. }))
  1821. );
  1822.  
  1823. Promise.all(promises)
  1824. .then(tracks => defer.resolve(tracks))
  1825. .catch(e => defer.reject(e));
  1826. })
  1827. .catch(e => {
  1828. self.logger.error('[Monochrome] explodeUri album error: ' + e.message);
  1829. defer.reject(e);
  1830. });
  1831. return defer.promise;
  1832. }
  1833.  
  1834.  
  1835. let clean = String(uri).trim();
  1836.  
  1837.  
  1838. // Znormalizuj schemat na postać ścieżki
  1839. if (clean.startsWith('monochrome://')) {
  1840. clean = clean.replace('monochrome://', 'monochrome/');
  1841. }
  1842.  
  1843. // Usuń prefix serwisu
  1844. if (clean.startsWith('monochrome/')) {
  1845. clean = clean.substring('monochrome/'.length);
  1846. } else if (clean.startsWith('monochrome')) {
  1847. // awaryjnie: jeśli ktoś da "monochromeplay/123" itp.
  1848. clean = clean.substring('monochrome'.length);
  1849. }
  1850.  
  1851. clean = clean.replace(/^\/+/, '');
  1852. const parts = clean.split('/').filter(Boolean);
  1853.  
  1854. const kind = parts[0]; // play / add
  1855. const rawId = parts[1];
  1856.  
  1857. if ((kind !== 'play' && kind !== 'add') || !rawId) {
  1858. defer.reject(new Error('Invalid URI ' + uri));
  1859. return defer.promise;
  1860. }
  1861.  
  1862. let id = rawId;
  1863. try {
  1864. id = decodeURIComponent(rawId);
  1865. } catch (e) {
  1866. // jeśli nie było encodowane, zostaw jak jest
  1867. id = rawId;
  1868. }
  1869.  
  1870. const quality =
  1871. (self.configObject && self.configObject.quality) ||
  1872. (self.config ? self.config.get('quality') : null) ||
  1873. 'LOSSLESS';
  1874.  
  1875. self.api.getTrackStream(id, quality)
  1876. .then((streamInfo) => {
  1877. self.currentTrack = streamInfo;
  1878.  
  1879. if (!streamInfo || !streamInfo.url) {
  1880. throw new Error('Missing stream URL for id=' + id);
  1881. }
  1882.  
  1883. let srText = streamInfo.samplerateText || streamInfo.samplerate;
  1884. let bdText = streamInfo.bitdepthText || streamInfo.bitdepth;
  1885.  
  1886. if (streamInfo.trackType === 'mp4') {
  1887. srText = null;
  1888. bdText = streamInfo.bitrate ? `${streamInfo.bitrate} kbps` : 'AAC';
  1889. }
  1890.  
  1891. console.log('[Monochrome] streamInfo._sourceIcon =', streamInfo._sourceIcon);
  1892. console.log('[Monochrome] icon final =', streamInfo._sourceIcon || '/albumart?sourceicon=music_service/monochrome/icon.png');
  1893.  
  1894. const trackItem = {
  1895. service: 'mpd',
  1896. type: 'song',
  1897. uri: streamInfo.url,
  1898. title: streamInfo.title,
  1899. name: streamInfo.title,
  1900. artist: streamInfo.artist,
  1901. album: streamInfo.album,
  1902. albumart: streamInfo.albumart,
  1903. duration: streamInfo.duration || 0,
  1904. trackType: streamInfo._sourceName,
  1905. //samplerate: srText,
  1906. // bitdepth: bdText,
  1907. icon: streamInfo._sourceIcon || '/albumart?sourceicon=music_service/monochrome/assets/icon.png',
  1908. codec: streamInfo.trackType
  1909. };
  1910.  
  1911. // AAC/MP4 (Monochrome API: trackType 'm4a' dla lossy)
  1912. if (String(streamInfo.trackType).toLowerCase() === 'aac') {
  1913. // samplerate z API (u Ciebie dla lossy: 44100)
  1914. trackItem.samplerate = self._hzToKhzText(streamInfo.samplerate || 44100) || '44,1 kHz';
  1915.  
  1916. // pokaż bitrate w miejscu "bitdepth"
  1917. trackItem.bitdepth = '320 kbps';
  1918.  
  1919. // opcjonalnie (żeby było jasne)
  1920. // trackItem.codec = 'aac';
  1921. } else {
  1922. // FLAC zostawiamy w spokoju, jak prosiłeś
  1923. }
  1924.  
  1925.  
  1926. // KLUCZ: żadnego play/add przez mpc tutaj.
  1927. defer.resolve([trackItem]);
  1928. })
  1929. .catch((e) => {
  1930. self.logger.error('[Monochrome] explodeUri error: ' + (e && e.message ? e.message : e));
  1931. defer.reject(e);
  1932. });
  1933.  
  1934. return defer.promise;
  1935. };
  1936.  
  1937.  
  1938.  
  1939.  
  1940.  
  1941. // ------------------------------------------------------------------
  1942. // TRANSPORT
  1943. // ------------------------------------------------------------------
  1944.  
  1945. ControllerMonochrome.prototype.play = function() {
  1946. return libQ.resolve();
  1947. };
  1948.  
  1949. ControllerMonochrome.prototype.pause = function() {
  1950. return this.commandRouter.volumioPause();
  1951. };
  1952.  
  1953. ControllerMonochrome.prototype.stop = function() {
  1954. return this.commandRouter.volumioStop();
  1955. };
  1956.  
  1957. ControllerMonochrome.prototype.resume = function() {
  1958. return this.commandRouter.volumioPlay();
  1959. };
  1960.  
  1961. ControllerMonochrome.prototype.seek = function(position) {
  1962. return this.commandRouter.volumioSeek(position);
  1963. };
  1964.  
  1965. ControllerMonochrome.prototype.next = function() {
  1966. return this.commandRouter.volumioNext();
  1967. };
  1968.  
  1969. ControllerMonochrome.prototype.previous = function() {
  1970. return this.commandRouter.volumioPrevious();
  1971. };
  1972.  
  1973. // ------------------------------------------------------------------
  1974. // UI CONFIG – BEZPOŚREDNI ZAPIS/ODCZYT Z PLIKU
  1975. // ------------------------------------------------------------------
  1976.  
  1977. // ------------------------------------------------------------------
  1978. // UI CONFIG – pełna obsługa z fallbackiem i ustawianiem po indeksach
  1979. // ------------------------------------------------------------------
  1980.  
  1981. ControllerMonochrome.prototype.getUIConfig = function () {
  1982. const self = this;
  1983. const defer = libQ.defer();
  1984.  
  1985. const lang_code = this.commandRouter.sharedVars.get('language_code');
  1986.  
  1987. this.commandRouter.i18nJson(
  1988. path.join(__dirname, 'i18n', 'strings_' + lang_code + '.json'),
  1989. path.join(__dirname, 'i18n', 'strings_en.json'),
  1990. path.join(__dirname, 'UIConfig.json')
  1991. ).then((uiconf) => {
  1992. // Sukces – wypełniamy UI wartościami
  1993. self._populateUIConfig(uiconf);
  1994. defer.resolve(uiconf);
  1995. }).fail((e) => {
  1996. // Jeśli i18nJson zawiedzie (brak plików tłumaczeń), ładujemy bezpośrednio UIConfig.json
  1997. console.log('[Monochrome] i18nJson failed, falling back to direct require');
  1998. try {
  1999. const uiconf = require('./UIConfig.json');
  2000. self._populateUIConfig(uiconf);
  2001. defer.resolve(uiconf);
  2002. } catch (e2) {
  2003. console.error('[Monochrome] Direct require also failed:', e2.message);
  2004. defer.resolve({ sections: [] }); // Pusta konfiguracja – GUI się nie wyłoży
  2005. }
  2006. });
  2007.  
  2008. return defer.promise;
  2009. };
  2010.  
  2011. ControllerMonochrome.prototype._populateUIConfig = function (uiconf) {
  2012. const self = this;
  2013.  
  2014. // Odczytaj konfigurację z self.configObject (aktualna w pamięci) lub pliku
  2015. let config = self.configObject;
  2016. if (!config) {
  2017. if (!self.configFile) {
  2018. self.configFile = self.commandRouter.pluginManager.getConfigurationFile(self.context, 'config.json');
  2019. }
  2020. try {
  2021. config = fs.readJsonSync(self.configFile);
  2022. } catch (e) {
  2023. config = {};
  2024. }
  2025. }
  2026.  
  2027. // Domyślne wartości – zabezpieczenie na wypadek braku pól w config
  2028. const defaultInstances = [
  2029. 'https://api.monochrome.tf',
  2030. 'https://arran.monochrome.tf',
  2031. 'https://triton.squid.wtf'
  2032. ];
  2033.  
  2034. const instances = (Array.isArray(config.instances) && config.instances.length)
  2035. ? config.instances
  2036. : defaultInstances;
  2037.  
  2038. const qobuz_api_base = config.qobuz_api_base || 'https://qobuz.squid.wtf/api';
  2039. const source = (config.source && ['tidal', 'qobuz', 'auto'].includes(config.source))
  2040. ? config.source
  2041. : 'tidal';
  2042. const quality = (typeof config.quality === 'string' && config.quality.trim() !== '')
  2043. ? config.quality
  2044. : 'LOSSLESS';
  2045. const search_limit = (typeof config.search_limit === 'number') ? config.search_limit : 20;
  2046. const timeout = (typeof config.timeout === 'number') ? config.timeout : 7000;
  2047. const album_sort = (config.album_sort && ['newest', 'oldest', 'title'].includes(config.album_sort))
  2048. ? config.album_sort
  2049. : 'newest';
  2050. const enable_artist_search = !!config.enable_artist_search;
  2051. const enable_album_search = !!config.enable_album_search;
  2052. const enable_playlist_search = !!config.enable_playlist_search;
  2053.  
  2054. // Pobierz content z sekcji settings
  2055. let content = uiconf.sections[0].content;
  2056.  
  2057. // Jeśli content jest obiektem (a nie tablicą), konwertuj na tablicę
  2058. if (!Array.isArray(content)) {
  2059. console.log('[Monochrome] content is object, converting to array');
  2060. content = Object.values(content);
  2061. uiconf.sections[0].content = content; // zastąp oryginał tablicą
  2062. }
  2063.  
  2064. // Ustaw wartości po indeksach – zgodnie z kolejnością pól w UIConfig.json
  2065. if (content.length > 0) {
  2066. // 0: instances (input text)
  2067. if (content[0]) content[0].value = instances.join(' ');
  2068.  
  2069. // 1: qobuz_api_base (input text)
  2070. if (content[1]) content[1].value = qobuz_api_base;
  2071.  
  2072. // 2: source (select)
  2073. if (content[2]) {
  2074. content[2].value = {
  2075. value: source,
  2076. label: self.getLabelForSelect(content[2].options, source)
  2077. };
  2078. }
  2079.  
  2080. // 3: quality (select)
  2081. if (content[3]) {
  2082. content[3].value = {
  2083. value: quality,
  2084. label: self.getLabelForSelect(content[3].options, quality)
  2085. };
  2086. }
  2087.  
  2088. // 4: search_limit (input number)
  2089. if (content[4]) content[4].value = search_limit;
  2090.  
  2091. // 5: timeout (input number)
  2092. if (content[5]) content[5].value = timeout;
  2093.  
  2094. // 6: album_sort (select)
  2095. if (content[6]) {
  2096. content[6].value = {
  2097. value: album_sort,
  2098. label: self.getLabelForSelect(content[6].options, album_sort)
  2099. };
  2100. }
  2101.  
  2102. // 7: enable_artist_search (switch)
  2103. if (content[7]) content[7].value = enable_artist_search;
  2104.  
  2105. // 8: enable_album_search (switch)
  2106. if (content[8]) content[8].value = enable_album_search;
  2107.  
  2108. // 9: enable_playlist_search (switch)
  2109. if (content[9]) content[9].value = enable_playlist_search;
  2110. }
  2111.  
  2112. // ---------- SEKCJA SPOTIFY ----------
  2113. const spotifySection = uiconf.sections.find(s => s && s.id === 'spotify');
  2114. if (spotifySection) {
  2115. let contentSpotify = spotifySection.content;
  2116. if (!Array.isArray(contentSpotify)) {
  2117. contentSpotify = Object.values(contentSpotify);
  2118. spotifySection.content = contentSpotify;
  2119. }
  2120.  
  2121. const setField = (id, value) => {
  2122. const field = contentSpotify.find(c => c && c.id === id);
  2123. if (field) field.value = value;
  2124. };
  2125.  
  2126. setField('spotify_client_id', config.spotify_client_id || '');
  2127. setField('spotify_client_secret', config.spotify_client_secret || '');
  2128. setField('spotify_playlist_url', config.spotify_playlist_url || '');
  2129. }
  2130.  
  2131. console.log('[Monochrome] _populateUIConfig - done');
  2132. };
  2133.  
  2134.  
  2135.  
  2136. // Funkcja pomocnicza do znajdowania etykiety dla danej wartości selecta
  2137. ControllerMonochrome.prototype.getLabelForSelect = function (options, value) {
  2138. if (!Array.isArray(options)) return value;
  2139. for (let i = 0; i < options.length; i++) {
  2140. if (options[i].value === value) {
  2141. return options[i].label;
  2142. }
  2143. }
  2144. return value; // fallback
  2145. };
  2146.  
  2147.  
  2148.  
  2149. ControllerMonochrome.prototype.setUIConfig = function (data) {
  2150. const self = this;
  2151. const defer = libQ.defer();
  2152.  
  2153. function unwrap(v) {
  2154. if (v && typeof v === 'object' && v.value !== undefined) return v.value;
  2155. return v;
  2156. }
  2157.  
  2158. try {
  2159. console.log('[Monochrome] setUIConfig received:', JSON.stringify(data));
  2160.  
  2161. if (!self.configFile) {
  2162. self.configFile = self.commandRouter.pluginManager.getConfigurationFile(self.context, 'config.json');
  2163. }
  2164.  
  2165. let config = {};
  2166. if (fs.existsSync(self.configFile)) {
  2167. try { config = fs.readJsonSync(self.configFile) || {}; }
  2168. catch (e) { config = {}; }
  2169. }
  2170.  
  2171. // qobuz_api_base
  2172. let qobuzApiBase = unwrap(data.qobuz_api_base);
  2173. if (typeof qobuzApiBase === 'string' && qobuzApiBase.trim() !== '' && qobuzApiBase.trim().startsWith('http')) {
  2174. config.qobuz_api_base = qobuzApiBase.trim();
  2175. }
  2176.  
  2177. // instances
  2178. if (data.instances !== undefined) {
  2179. let instancesRaw = unwrap(data.instances);
  2180. instancesRaw = String(instancesRaw || '').trim();
  2181. const instancesArray = instancesRaw.split(/\s+/).filter(s => s.startsWith('http'));
  2182. config.instances = instancesArray.length ? instancesArray : [
  2183. 'https://api.monochrome.tf',
  2184. 'https://arran.monochrome.tf',
  2185. 'https://triton.squid.wtf'
  2186. ];
  2187. }
  2188.  
  2189. // source
  2190. let sourceValue = unwrap(data.source);
  2191. if (['tidal', 'qobuz', 'auto'].includes(sourceValue)) config.source = sourceValue;
  2192.  
  2193. // quality
  2194. let qualityValue = unwrap(data.quality);
  2195. if (typeof qualityValue === 'string' && qualityValue.trim() !== '') config.quality = qualityValue.trim();
  2196.  
  2197. // search_limit
  2198. if (data.search_limit !== undefined) {
  2199. const v = parseInt(unwrap(data.search_limit), 10);
  2200. if (!isNaN(v)) config.search_limit = v;
  2201. }
  2202.  
  2203. // timeout
  2204. if (data.timeout !== undefined) {
  2205. const v = parseInt(unwrap(data.timeout), 10);
  2206. if (!isNaN(v)) config.timeout = v;
  2207. }
  2208.  
  2209. // album_sort
  2210. let albumSortValue = unwrap(data.album_sort);
  2211. if (['newest', 'oldest', 'title'].includes(albumSortValue)) config.album_sort = albumSortValue;
  2212.  
  2213. // booleans
  2214. ['enable_artist_search', 'enable_album_search', 'enable_playlist_search'].forEach((key) => {
  2215. if (data[key] !== undefined) config[key] = !!unwrap(data[key]);
  2216. });
  2217.  
  2218. // Spotify (jeśli masz w UI)
  2219. if (data.spotify_client_id !== undefined) config.spotify_client_id = String(unwrap(data.spotify_client_id) || '').trim();
  2220. if (data.spotify_client_secret !== undefined) config.spotify_client_secret = String(unwrap(data.spotify_client_secret) || '').trim();
  2221. if (data.spotify_playlist_url !== undefined) config.spotify_playlist_url = String(unwrap(data.spotify_playlist_url) || '').trim();
  2222.  
  2223. fs.writeJsonSync(self.configFile, config, { spaces: 2 });
  2224. console.log('[Monochrome] Config saved to file:', self.configFile);
  2225.  
  2226. self.configObject = config;
  2227.  
  2228. if (!self.config) self.config = new vConf();
  2229. self.config.loadFile(self.configFile);
  2230.  
  2231. const apiSettings = {
  2232. getInstances: async () => config.instances || ['https://api.monochrome.tf'],
  2233. getConf: async (key, defVal) => (Object.prototype.hasOwnProperty.call(config, key) ? config[key] : defVal)
  2234. };
  2235. self.api = new MultiSourceAPI(apiSettings);
  2236.  
  2237. self.commandRouter.pushToastMessage('success', 'Settings Saved', 'Monochrome settings updated.');
  2238. defer.resolve();
  2239. } catch (e) {
  2240. console.error('[Monochrome] setUIConfig error:', e);
  2241. defer.reject(e);
  2242. }
  2243.  
  2244. return defer.promise;
  2245. };
  2246.  
  2247.  
  2248. // ------------------------------------------------------------------
  2249. // METODY POMOCNICZE DLA UI (testy, reset)
  2250. // ------------------------------------------------------------------
  2251.  
  2252. ControllerMonochrome.prototype.testConnection = async function () {
  2253. const self = this;
  2254. try {
  2255. const results = await self.api.testConnection();
  2256. const working = results.filter(r => r.status === 'OK').length;
  2257. const failed = results.filter(r => r.status !== 'OK').length;
  2258. self.commandRouter.pushToastMessage('info', 'Monochrome', `Connection test: ${working} working, ${failed} failed`);
  2259. } catch (e) {
  2260. self.commandRouter.pushToastMessage('error', 'Monochrome', 'Test failed: ' + e.message);
  2261. }
  2262. };
  2263.  
  2264. ControllerMonochrome.prototype.clearCache = function () {
  2265. this.api.clearCache();
  2266. this.commandRouter.pushToastMessage('success', 'Monochrome', 'Cache cleared');
  2267. };
  2268.  
  2269. ControllerMonochrome.prototype.resetPlugin = function () {
  2270. const self = this;
  2271. try {
  2272. if (fs.existsSync(self.configFile)) {
  2273. fs.unlinkSync(self.configFile);
  2274. }
  2275. self.commandRouter.pushToastMessage('success', 'Monochrome', 'Plugin reset, please restart Volumio');
  2276. } catch (e) {
  2277. self.commandRouter.pushToastMessage('error', 'Monochrome', 'Reset failed: ' + e.message);
  2278. }
  2279. };
  2280.  
  2281.  
  2282.  
  2283. ControllerMonochrome.prototype.isFavoritePlaylist = function (playlistId) {
  2284. const favs = this.loadFavoritePlaylists();
  2285. return favs.some(p => String(p.id) === String(playlistId));
  2286. };
  2287.  
  2288.  
  2289. ControllerMonochrome.prototype.getFavoritesFilePath = function () {
  2290. if (!this.configFile) {
  2291. this.configFile = this.commandRouter.pluginManager.getConfigurationFile(this.context, 'config.json');
  2292. }
  2293. return this.configFile.replace('config.json', FAVORITES_FILE);
  2294. };
  2295.  
  2296. ControllerMonochrome.prototype.loadFavoritePlaylists = function () {
  2297. const file = this.getFavoritesFilePath();
  2298. console.log('[Monochrome] favorites file path:', file);
  2299.  
  2300. if (!fs.existsSync(file)) {
  2301. console.log('[Monochrome] favorites file missing');
  2302. return [];
  2303. }
  2304.  
  2305. try {
  2306. const data = fs.readJsonSync(file);
  2307. console.log('[Monochrome] favorites keys:', Object.keys(data || {}));
  2308. console.log('[Monochrome] favorites playlists length:', Array.isArray(data?.playlists) ? data.playlists.length : 'NOT_ARRAY');
  2309. return Array.isArray(data.playlists) ? data.playlists : [];
  2310. } catch (e) {
  2311. console.log('[Monochrome] favorites readJsonSync error:', e.message);
  2312. return [];
  2313. }
  2314. };
  2315.  
  2316.  
  2317. ControllerMonochrome.prototype.saveFavoritePlaylists = function (playlists) {
  2318. const file = this.getFavoritesFilePath();
  2319. let data = {};
  2320. try { if (fs.existsSync(file)) data = fs.readJsonSync(file) || {}; } catch (e) {}
  2321. data.playlists = playlists || [];
  2322. fs.writeJsonSync(file, data, { spaces: 2 });
  2323. };
  2324.  
  2325.  
  2326. ControllerMonochrome.prototype.toggleFavoritePlaylist = function (playlistObj) {
  2327. // playlistObj: { id, title, uri, cover, type } – type może być 'tidal' lub 'spotify'
  2328. const favs = this.loadFavoritePlaylists();
  2329. const pid = String(playlistObj.id);
  2330.  
  2331. const idx = favs.findIndex(p => String(p.id) === pid);
  2332. if (idx >= 0) {
  2333. favs.splice(idx, 1);
  2334. this.saveFavoritePlaylists(favs);
  2335. return { added: false };
  2336. }
  2337.  
  2338. favs.unshift({
  2339. id: pid,
  2340. title: playlistObj.title,
  2341. uri: playlistObj.uri,
  2342. cover: playlistObj.cover || null,
  2343. type: playlistObj.type || 'tidal', // domyślnie tidal, dla Spotify ustawiamy 'spotify'
  2344. autoRefresh: playlistObj.autoRefresh || false, // domyślnie false
  2345. lastRefreshed: playlistObj.type === 'spotify' ? new Date().toISOString() : null
  2346. });
  2347. this.saveFavoritePlaylists(favs);
  2348. return { added: true };
  2349. };
  2350.  
  2351.  
  2352.  
  2353.  
  2354. ControllerMonochrome.prototype.importSpotifyPlaylistNow = async function () {
  2355. const self = this;
  2356. try {
  2357. self.logger.info('[Monochrome] Starting Spotify import');
  2358.  
  2359. const spotifyUrl = self.configObject?.spotify_playlist_url || self.config.get('spotify_playlist_url');
  2360. if (!spotifyUrl) {
  2361. self.logger.error('[Monochrome] No Spotify playlist URL provided');
  2362. self.commandRouter.pushToastMessage('error', 'Monochrome', 'No Spotify playlist URL provided');
  2363. return;
  2364. }
  2365.  
  2366. const clientId = self.configObject?.spotify_client_id || self.config.get('spotify_client_id');
  2367. const clientSecret = self.configObject?.spotify_client_secret || self.config.get('spotify_client_secret');
  2368.  
  2369. const importer = require('./spotify_importer');
  2370. const playlistId = importer.parsePlaylistId(spotifyUrl);
  2371. self.logger.info('[Monochrome] Spotify import: playlist ID = ' + playlistId);
  2372.  
  2373. let meta, tracks;
  2374. let usedFallback = false;
  2375.  
  2376. if (clientId && clientSecret) {
  2377. try {
  2378. self.logger.info('[Monochrome] Trying official Spotify API...');
  2379. const token = await importer.getAppToken(clientId, clientSecret);
  2380. meta = await importer.getPlaylistMeta(playlistId, token);
  2381. tracks = await importer.getPlaylistTracksAll(playlistId, token);
  2382. self.logger.info(`[Monochrome] Official API succeeded: ${tracks.length} tracks`);
  2383. } catch (apiError) {
  2384. if (apiError.message.includes('404') || apiError.message.includes('Resource not found')) {
  2385. self.logger.warn('[Monochrome] Official API returned 404, falling back to guest method...');
  2386. usedFallback = true;
  2387. } else {
  2388. throw apiError;
  2389. }
  2390. }
  2391. } else {
  2392. self.logger.info('[Monochrome] No Spotify credentials, using guest fallback directly');
  2393. usedFallback = true;
  2394. }
  2395.  
  2396. if (usedFallback) {
  2397. try {
  2398. const fallbackResult = await self._fetchSpotifyViaRust(spotifyUrl);
  2399. meta = {
  2400. id: playlistId,
  2401. title: fallbackResult.title || 'Imported Playlist',
  2402. cover: fallbackResult.cover || null
  2403. };
  2404. tracks = fallbackResult.tracks.map(t => ({
  2405. title: t.title,
  2406. artists: t.artists,
  2407. isrc: t.isrc || null,
  2408. spotifyUri: t.spotifyUri || null,
  2409. album: t.album || '',
  2410. cover: t.cover || null
  2411. }));
  2412. self.logger.info(`[Monochrome] Guest fallback succeeded: ${tracks.length} tracks`);
  2413. } catch (fallbackError) {
  2414. self.logger.error('[Monochrome] Guest fallback also failed: ' + fallbackError.message);
  2415. throw new Error('Both official API and guest fallback failed');
  2416. }
  2417. }
  2418.  
  2419. // Mapowanie do Tidal/Qobuz (bez zmian)
  2420. const mapped = [];
  2421. for (const t of tracks) {
  2422. const q = `${(t.artists[0] || '').trim()} ${t.title}`.trim();
  2423. if (!q) {
  2424. mapped.push({ spotify: t, mapped: null });
  2425. continue;
  2426. }
  2427. try {
  2428. const res = await self.api.searchTracks(q, { limit: 5 });
  2429. const best = res?.items?.[0] || null;
  2430. mapped.push({
  2431. spotify: t,
  2432. mapped: best ? {
  2433. id: String(best.id),
  2434. title: best.title,
  2435. artist: best.artist?.name,
  2436. album: best.album?.title,
  2437. cover: best.cover
  2438. } : null
  2439. });
  2440. } catch (e) {
  2441. self.logger.error('[Monochrome] Error mapping track: ' + e.message);
  2442. mapped.push({ spotify: t, mapped: null });
  2443. }
  2444. }
  2445.  
  2446. // Zapis do favorites.json (bez zmian)
  2447. const favFile = self.getFavoritesFilePath();
  2448. let data = {};
  2449. try {
  2450. if (fs.existsSync(favFile)) data = fs.readJsonSync(favFile) || {};
  2451. } catch (e) {
  2452. self.logger.error('[Monochrome] Error reading favorites file: ' + e.message);
  2453. }
  2454. if (!data.playlists) data.playlists = [];
  2455. if (!data.spotifyImported) data.spotifyImported = {};
  2456. const now = new Date().toISOString();
  2457. data.spotifyImported[playlistId] = {
  2458. spotifyUrl,
  2459. title: meta.title,
  2460. cover: meta.cover,
  2461. importedAt: new Date().toISOString(),
  2462. tracks: mapped,
  2463. lastRefreshed: now,
  2464. autoRefresh: false
  2465. };
  2466.  
  2467. const favId = `spotify:${playlistId}`;
  2468. const favUri = `monochrome_spotifypl:${playlistId}`;
  2469. const exists = data.playlists.some(p => String(p.id) === favId);
  2470. if (!exists) {
  2471. data.playlists.unshift({ id: favId, title: meta.title, uri: favUri, cover: meta.cover, type: 'spotify', autoRefresh: false, lastRefreshed: now});
  2472. } else {
  2473. data.playlists = data.playlists.map(p => String(p.id) === favId ? { ...p, title: meta.title, cover: meta.cover, uri: favUri } : p);
  2474. }
  2475.  
  2476. fs.writeJsonSync(favFile, data, { spaces: 2 });
  2477. self.logger.info('[Monochrome] Spotify import completed successfully');
  2478. self.commandRouter.pushToastMessage('success', 'Monochrome', `Imported ${tracks.length} tracks, ${mapped.filter(x => x.mapped).length} matched.`);
  2479. } catch (error) {
  2480. self.logger.error('[Monochrome] Spotify import error: ' + error.message);
  2481. self.commandRouter.pushToastMessage('error', 'Monochrome', 'Import failed: ' + error.message);
  2482. }
  2483. };
  2484.  
  2485.  
  2486.  
  2487. ControllerMonochrome.prototype.loadFavoritesData = function () {
  2488. const file = this.getFavoritesFilePath();
  2489. if (!fs.existsSync(file)) return { playlists: [], spotifyImported: {} };
  2490. try {
  2491. const data = fs.readJsonSync(file) || {};
  2492. if (!Array.isArray(data.playlists)) data.playlists = [];
  2493. if (!data.spotifyImported || typeof data.spotifyImported !== 'object') data.spotifyImported = {};
  2494. return data;
  2495. } catch (e) {
  2496. return { playlists: [], spotifyImported: {} };
  2497. }
  2498. };
  2499.  
  2500. ControllerMonochrome.prototype.saveFavoritesData = function (data) {
  2501. const file = this.getFavoritesFilePath();
  2502. fs.writeJsonSync(file, data || { playlists: [], spotifyImported: {} }, { spaces: 2 });
  2503. };
  2504.  
  2505.  
  2506. ControllerMonochrome.prototype._fetchSpotifyViaRust = async function (playlistUrl) {
  2507. const self = this;
  2508. const binPath = path.join(__dirname, 'bin', 'spotify_fallback');
  2509.  
  2510. try {
  2511. const { stdout, stderr } = await execPromise(`"${binPath}" "${playlistUrl}"`);
  2512. if (stderr) {
  2513. self.logger.warn('[Monochrome] Rust fallback stderr: ' + stderr);
  2514. }
  2515. const result = JSON.parse(stdout);
  2516. if (result.error) throw new Error(result.error);
  2517. return result;
  2518. } catch (err) {
  2519. throw new Error(`Rust fallback failed: ${err.message}`);
  2520. }
  2521. };
  2522.  
  2523.  
  2524. // Sprawdza, czy playlistę należy odświeżyć (autoRefresh włączone i minęły >=2 dni od lastRefreshed)
  2525. ControllerMonochrome.prototype._shouldRefreshPlaylist = function (playlist) {
  2526. if (!playlist.autoRefresh) return false;
  2527. if (!playlist.lastRefreshed) return true; // nigdy nie odświeżana
  2528. const last = new Date(playlist.lastRefreshed);
  2529. const now = new Date();
  2530. const diffDays = (now - last) / (1000 * 60 * 60 * 24);
  2531. return diffDays >= 2;
  2532. };
  2533.  
  2534. // Aktualizuje timestamp ostatniego odświeżenia dla playlisty o podanym ID
  2535. ControllerMonochrome.prototype._updatePlaylistRefreshTime = function (playlistId) {
  2536. const data = this.loadFavoritesData();
  2537. const now = new Date().toISOString();
  2538. const playlistEntry = data.playlists.find(p => String(p.id) === `spotify:${playlistId}`);
  2539. if (playlistEntry) {
  2540. playlistEntry.lastRefreshed = now;
  2541. }
  2542. if (data.spotifyImported && data.spotifyImported[playlistId]) {
  2543. data.spotifyImported[playlistId].lastRefreshed = now;
  2544. }
  2545. this.saveFavoritesData(data);
  2546. };
  2547.  
  2548.  
  2549. ControllerMonochrome.prototype._refreshSingleSpotifyPlaylist = async function (playlistId) {
  2550. const self = this;
  2551. const data = self.loadFavoritesData();
  2552. const existing = data.spotifyImported?.[playlistId];
  2553. if (!existing) {
  2554. throw new Error(`Playlist ${playlistId} not found in spotifyImported`);
  2555. }
  2556.  
  2557. const spotifyUrl = `https://open.spotify.com/playlist/${playlistId}`;
  2558. const fallbackResult = await self._fetchSpotifyViaRust(spotifyUrl);
  2559.  
  2560. const meta = {
  2561. id: playlistId,
  2562. title: fallbackResult.title || existing.title,
  2563. cover: fallbackResult.cover || existing.cover
  2564. };
  2565.  
  2566. const tracks = fallbackResult.tracks.map(t => ({
  2567. title: t.title,
  2568. artists: t.artists,
  2569. isrc: t.isrc || null,
  2570. spotifyUri: t.spotifyUri || null,
  2571. album: t.album || '',
  2572. cover: t.cover || null
  2573. }));
  2574.  
  2575. // Mapowanie do Tidal/Qobuz (identyczne jak w import)
  2576. const mapped = [];
  2577. for (const t of tracks) {
  2578. const q = `${(t.artists[0] || '').trim()} ${t.title}`.trim();
  2579. if (!q) {
  2580. mapped.push({ spotify: t, mapped: null });
  2581. continue;
  2582. }
  2583. try {
  2584. const res = await self.api.searchTracks(q, { limit: 5 });
  2585. const best = res?.items?.[0] || null;
  2586. mapped.push({
  2587. spotify: t,
  2588. mapped: best ? {
  2589. id: String(best.id),
  2590. title: best.title,
  2591. artist: best.artist?.name,
  2592. album: best.album?.title,
  2593. cover: best.cover
  2594. } : null
  2595. });
  2596. } catch (e) {
  2597. self.logger.error('[Monochrome] Error mapping track during refresh: ' + e.message);
  2598. mapped.push({ spotify: t, mapped: null });
  2599. }
  2600. }
  2601.  
  2602. // Aktualizacja istniejącego wpisu
  2603. data.spotifyImported[playlistId] = {
  2604. ...existing,
  2605. title: meta.title,
  2606. cover: meta.cover,
  2607. lastRefreshed: new Date().toISOString(),
  2608. tracks: mapped
  2609. };
  2610.  
  2611. // Aktualizacja również w playlists (jeśli istnieje)
  2612. const favId = `spotify:${playlistId}`;
  2613. const playlistEntry = data.playlists.find(p => String(p.id) === favId);
  2614. if (playlistEntry) {
  2615. playlistEntry.title = meta.title;
  2616. playlistEntry.cover = meta.cover;
  2617. playlistEntry.lastRefreshed = new Date().toISOString();
  2618. }
  2619.  
  2620. self.saveFavoritesData(data);
  2621. self.logger.info(`[Monochrome] Refreshed playlist: ${meta.title} (${playlistId})`);
  2622. };
  2623.  
  2624.  
  2625. // Przechodzi przez wszystkie playlisty i odświeża te, które wymagają odświeżenia
  2626. ControllerMonochrome.prototype._refreshExpiredPlaylists = async function () {
  2627. const self = this;
  2628. self.logger.info('[Monochrome] Checking for expired Spotify playlists to refresh...');
  2629.  
  2630. const data = self.loadFavoritesData();
  2631. const spotifyPlaylists = data.playlists.filter(p => p.type === 'spotify');
  2632.  
  2633. let refreshedCount = 0;
  2634. for (const pl of spotifyPlaylists) {
  2635. if (self._shouldRefreshPlaylist(pl)) {
  2636. const playlistId = pl.id.replace('spotify:', '');
  2637. try {
  2638. self.logger.info(`[Monochrome] Refreshing playlist: ${pl.title} (${playlistId})`);
  2639. await self._refreshSingleSpotifyPlaylist(playlistId);
  2640. refreshedCount++;
  2641. } catch (e) {
  2642. self.logger.error(`[Monochrome] Failed to refresh playlist ${playlistId}: ${e.message}`);
  2643. }
  2644. }
  2645. }
  2646.  
  2647. self.logger.info(`[Monochrome] Refresh check completed. Refreshed ${refreshedCount} playlists.`);
  2648. };
  2649.  
  2650. ControllerMonochrome.prototype._checkMpdState = function () {
  2651. const self = this;
  2652.  
  2653. try {
  2654. // 1) Pobieramy aktualny (tani) stan z Volumio
  2655. const vs = self.commandRouter.volumioGetState();
  2656. if (!vs || vs.service !== 'mpd') return;
  2657.  
  2658. const now = Date.now();
  2659.  
  2660. // 2) Anti-spam (np. timer co 60s chroni przed zapętleniem żądań)
  2661. const cooldownMs = 10 * 60 * 1000; // 10 min
  2662. if (self._lastQueueClearAt && (now - self._lastQueueClearAt) < cooldownMs) return;
  2663.  
  2664. // 3) Odpytujemy MPD prosto z warstwy silnika (libQ/kew)
  2665. self.commandRouter.executeOnPlugin('music_service', 'mpd', 'getState', '')
  2666. .then((state) => {
  2667. if (!state || !state.status) return;
  2668.  
  2669. const s = state.status; // 'play' | 'pause' | 'stop'
  2670.  
  2671. // Loguj tylko zmianę stanu
  2672. if (s !== self.currentState) {
  2673. self.logger.info(`[Monochrome] MPD status: ${self.currentState} -> ${s}`);
  2674. self.currentState = s;
  2675. }
  2676.  
  2677. // 4) Jeżeli odtwarza - sprawdzamy czy to Monochrome. Jeżeli tak - resetujemy licznik czasu.
  2678. if (s === 'play') {
  2679. // W stanie 'play' warunek posiadania odpowiedniego URI ma sens
  2680. if (self._isMonochromeMpdUri(vs.uri)) {
  2681. self.lastPlayTime = now;
  2682. }
  2683. return;
  2684. }
  2685.  
  2686. // 5) Skoro jesteśmy tutaj, urządzenie stoi (pause / stop).
  2687. // Warunek idle liczymy niezależnie od aktualnego "uri" (bo w 'stop' uri mogło spaść)
  2688. const fourHours = 4 * 60 * 60 * 1000;
  2689. const idleTooLong = self.lastPlayTime && (now - self.lastPlayTime > fourHours);
  2690.  
  2691. if ((s === 'pause' || s === 'stop') && idleTooLong) {
  2692. self.logger.info('[Monochrome] Odtwarzacz nieaktywny od ponad 4h -> Twarde czyszczenie kolejki i dysku.');
  2693.  
  2694. self._lastQueueClearAt = now;
  2695. self.lastPlayTime = null; // Zabezpieczenie przed zapętleniem
  2696.  
  2697. try {
  2698. // Najpewniejsza komenda czyszcząca system Volumio (lepsza niż API)
  2699. self.commandRouter.stateMachine.clearQueue();
  2700. self.logger.info('[Monochrome] Kolejka została pomyślnie i bezpowrotnie zrzucona.');
  2701. } catch (e) {
  2702. self.commandRouter.volumioClearQueue();
  2703. self.logger.info('[Monochrome] Kolejka zrzucona poprzez fallback API.');
  2704. }
  2705.  
  2706. // Czyszczenie "śmieci" z dysku DASH
  2707. if (typeof self._cleanUnusedDashFiles === 'function') {
  2708. self._cleanUnusedDashFiles(0).catch(err => {
  2709. self.logger.error('[Monochrome] Błąd czyszczenia plików po bezczynności: ' + (err.message || String(err)));
  2710. });
  2711. }
  2712. }
  2713. })
  2714. .fail((e) => {
  2715. self.logger.error('[Monochrome] mpd getState error: ' + (e?.message || String(e)));
  2716. });
  2717.  
  2718. } catch (e) {
  2719. self.logger.error('[Monochrome] _checkMpdState exception: ' + (e?.message || String(e)));
  2720. }
  2721. };
  2722.  
  2723.  
  2724.  
  2725. ControllerMonochrome.prototype._isMonochromeMpdUri = function (uri) {
  2726. if (!uri || typeof uri !== 'string') return false;
  2727.  
  2728. // 1) Lokalne pliki z temp/dash wtyczki
  2729. if (uri.startsWith('file:///data/plugins/music_service/monochrome/')) return true;
  2730. if (uri.startsWith('/data/plugins/music_service/monochrome/')) return true;
  2731.  
  2732. // 2) Zdalne streamy (Tidal CDN) – dopisz hosty, które u Ciebie realnie występują
  2733. // Przykład z loga: lgf.audio.tidal.com
  2734. if (uri.startsWith('https://') || uri.startsWith('http://')) {
  2735. try {
  2736. const u = new URL(uri);
  2737. const h = (u.hostname || '').toLowerCase();
  2738.  
  2739. // Tidal CDN (często: *.audio.tidal.com, resources.tidal.com to okładki)
  2740. if (h.endsWith('.audio.tidal.com')) return true;
  2741. if (h === 'lgf.audio.tidal.com') return true;
  2742.  
  2743. // jeśli kiedyś dodasz Qobuz bezpośrednio, dopisz tu ich hosty
  2744. } catch (e) {
  2745. // jak URL nie parsuje się, olej
  2746. }
  2747. }
  2748.  
  2749. return false;
  2750. };
  2751.  
  2752. ControllerMonochrome.prototype._hzToKhzText = function (hz) {
  2753. const n = parseInt(hz, 10);
  2754. if (!Number.isFinite(n) || n <= 0) return '';
  2755. const khz = n / 1000;
  2756. const s = (Math.round(khz * 10) / 10).toString().replace('.', ',');
  2757. return s + ' kHz';
  2758. };
  2759.  
  2760.  
  2761.  
  2762. ControllerMonochrome.prototype.getFreeBytesForPath = async function (dirPath) {
  2763. // POSIX df, wynik w bajtach (Available)
  2764. const safe = String(dirPath).replace(/'/g, "'\\''");
  2765. const { stdout } = await execPromise(`df -B1 '${safe}' | tail -1 | awk '{print $4}'`);
  2766. const n = parseInt(String(stdout).trim(), 10);
  2767. return Number.isFinite(n) ? n : 0;
  2768. };
  2769.  
  2770. ControllerMonochrome.prototype.cleanDashIfLowSpace = async function () {
  2771. const self = this;
  2772. const tempDir = 'data/plugins/music_service/monochrome/temp/dash';
  2773. const thresholdBytes = 1 * 1024 * 1024 * 1024; // 1 GB
  2774.  
  2775. let free = await self.getFreeBytesForPath(tempDir);
  2776. if (free >= thresholdBytes) return;
  2777.  
  2778. self.logger.warn(`[Monochrome] Low disk space: free=${(free/1024/1024).toFixed(0)}MB, cleaning DASH...`);
  2779.  
  2780. // Sprzątamy agresywnie: maxAgeMs=0 => usuń wszystko nieużywane niezależnie od wieku
  2781. // (u Ciebie cleanUnusedDashFiles ma logikę: maxAgeMs=0 usuwa nieużywane zawsze)
  2782. for (let i = 0; i < 20; i++) { // limit pętli bezpieczeństwa
  2783. await self._cleanUnusedDashFiles(0);
  2784.  
  2785. const newFree = await self.getFreeBytesForPath(tempDir);
  2786. if (newFree >= thresholdBytes) break;
  2787. if (newFree <= free) break; // nic już nie przybywa -> nie ma co usuwać
  2788. free = newFree;
  2789. }
  2790.  
  2791. self.logger.info(`[Monochrome] DASH cleanup finished, freeNow=${(free/1024/1024).toFixed(0)}MB`);
  2792. };
  2793.  
Advertisement
Add Comment
Please, Sign In to add comment