content: /*
   This file is part of Airsonic.
  
   Airsonic is free software: you can redistribute it and/or modify
   it under the terms of the GNU General Public License as published by
   the Free Software Foundation, either version 3 of the License, or
   (at your option) any later version.
  
   Airsonic is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
   GNU General Public License for more details.
  
   You should have received a copy of the GNU General Public License
   along with Airsonic.  If not, see <http://www.gnu.org/licenses/>.
  
   Copyright 2016 (C) Airsonic Authors
   Based upon Subsonic, Copyright 2009 (C) Sindre Mehus
   */
  package org.airsonic.player.controller;
  
  import org.airsonic.player.dao.DaoHelper;
  import org.airsonic.player.dao.MediaFileDao;
  import org.airsonic.player.dao.MusicFolderDao;
  import org.airsonic.player.domain.MediaLibraryStatistics;
  import org.airsonic.player.domain.MusicFolder;
  import org.airsonic.player.service.SecurityService;
  import org.airsonic.player.service.SettingsService;
  import org.airsonic.player.service.TranscodingService;
  import org.airsonic.player.service.VersionService;
  import org.airsonic.player.service.search.AnalyzerFactory;
  import org.airsonic.player.service.search.IndexManager;
  import org.airsonic.player.service.search.IndexType;
  import org.apache.commons.io.FileUtils;
  import org.apache.commons.io.input.ReversedLinesFileReader;
  import org.apache.lucene.analysis.Analyzer;
  import org.apache.lucene.index.IndexReader;
  import org.apache.lucene.search.IndexSearcher;
  import org.slf4j.Logger;
  import org.slf4j.LoggerFactory;
  import org.springframework.beans.factory.annotation.Autowired;
  import org.springframework.core.env.Environment;
  import org.springframework.stereotype.Controller;
  import org.springframework.web.bind.annotation.GetMapping;
  import org.springframework.web.bind.annotation.RequestMapping;
  import org.springframework.web.servlet.ModelAndView;
  
  import javax.servlet.http.HttpServletRequest;
  import javax.servlet.http.HttpServletResponse;
  
  import java.io.File;
  import java.io.IOException;
  import java.nio.charset.Charset;
  import java.nio.file.Files;
  import java.sql.Connection;
  import java.sql.ResultSet;
  import java.sql.SQLException;
  import java.util.*;
  
  /**
   * Controller for the help page.
   *
   * @author Sindre Mehus
   */
  @Controller
  @RequestMapping("/internalhelp")
  public class InternalHelpController {
  
      private static final Logger LOG = LoggerFactory.getLogger(InternalHelpController.class);
  
      private static final int LOG_LINES_TO_SHOW = 50;
  
      public class IndexStatistics {
          private String name;
          private int count;
          private int deletedCount;
  
          public String getName() {
              return name;
          }
  
          public void setName(String name) {
              this.name = name;
          }
  
          public int getCount() {
              return count;
          }
  
          public void setCount(int count) {
              this.count = count;
          }
  
          public int getDeletedCount() {
              return deletedCount;
          }
  
          public void setDeletedCount(int deletedCount) {
              this.deletedCount = deletedCount;
          }
      }
  
      public class FileStatistics {
          private String name;
          private String path;
          private String freeFilesystemSizeBytes;
          private String totalFilesystemSizeBytes;
          private boolean readable;
          private boolean writable;
          private boolean executable;
  
          public FileStatistics() {}
  
          public FileStatistics(File path) {
          }
  
          public String getName() {
              return name;
          }
  
          public String getFreeFilesystemSizeBytes() {
              return freeFilesystemSizeBytes;
          }
  
          public boolean isReadable() {
              return readable;
          }
  
          public boolean isWritable() {
              return writable;
          }
  
          public boolean isExecutable() {
              return executable;
          }
  
          public String getTotalFilesystemSizeBytes() {
              return totalFilesystemSizeBytes;
          }
  
          public String getPath() {
              return path;
          }
  
          public void setName(String name) {
              this.name = name;
          }
  
          public void setFreeFilesystemSizeBytes(String freeFilesystemSizeBytes) {
              this.freeFilesystemSizeBytes = freeFilesystemSizeBytes;
          }
  
          public void setReadable(boolean readable) {
              this.readable = readable;
          }
  
          public void setWritable(boolean writable) {
              this.writable = writable;
          }
  
          public void setExecutable(boolean executable) {
              this.executable = executable;
          }
  
          public void setTotalFilesystemSizeBytes(String totalFilesystemSizeBytes) {
              this.totalFilesystemSizeBytes = totalFilesystemSizeBytes;
          }
  
          public void setPath(String path) {
              this.path = path;
          }
  
          public void setFromFile(File file) {
              this.setName(file.getName());
              this.setPath(file.getAbsolutePath());
              this.setFreeFilesystemSizeBytes(FileUtils.byteCountToDisplaySize(file.getUsableSpace()));
              this.setTotalFilesystemSizeBytes(FileUtils.byteCountToDisplaySize(file.getTotalSpace()));
              this.setReadable(Files.isReadable(file.toPath()));
              this.setWritable(Files.isWritable(file.toPath()));
              this.setExecutable(Files.isExecutable(file.toPath()));
          }
      }
  
      @Autowired
      private VersionService versionService;
      @Autowired
      private SettingsService settingsService;
      @Autowired
      private SecurityService securityService;
      @Autowired
      private IndexManager indexManager;
      @Autowired
      private DaoHelper daoHelper;
      @Autowired
      private AnalyzerFactory analyzerFactory;
      @Autowired
      private MusicFolderDao musicFolderDao;
      @Autowired
      private MediaFileDao mediaFileDao;
      @Autowired
      private TranscodingService transcodingService;
      @Autowired
      private Environment environment;
  
      @GetMapping
      protected ModelAndView handleRequestInternal(HttpServletRequest request, HttpServletResponse response) {
          Map<String, Object> map = new HashMap<>();
  
          if (versionService.isNewFinalVersionAvailable()) {
              map.put("newVersionAvailable", true);
              map.put("latestVersion", versionService.getLatestFinalVersion());
          } else if (versionService.isNewBetaVersionAvailable()) {
              map.put("newVersionAvailable", true);
              map.put("latestVersion", versionService.getLatestBetaVersion());
          }
  
          long totalMemory = Runtime.getRuntime().totalMemory();
          long freeMemory = Runtime.getRuntime().freeMemory();
  
          String serverInfo = request.getSession().getServletContext().getServerInfo() +
                              ", java " + System.getProperty("java.version") +
                              ", " + System.getProperty("os.name");
  
          map.put("user", securityService.getCurrentUser(request));
          map.put("brand", settingsService.getBrand());
          map.put("localVersion", versionService.getLocalVersion());
          map.put("buildDate", versionService.getLocalBuildDate());
          map.put("buildNumber", versionService.getLocalBuildNumber());
          map.put("serverInfo", serverInfo);
          map.put("usedMemory", totalMemory - freeMemory);
          map.put("totalMemory", totalMemory);
          File logFile = SettingsService.getLogFile();
          List<String> latestLogEntries = getLatestLogEntries(logFile);
          map.put("logEntries", latestLogEntries);
          map.put("logFile", logFile);
  
          // Gather internal information
          gatherScanInfo(map);
          gatherIndexInfo(map);
          gatherDatabaseInfo(map);
          gatherFilesystemInfo(map);
          gatherTranscodingInfo(map);
          gatherLocaleInfo(map);
  
          return new ModelAndView("internalhelp","model",map);
      }
  
      private void gatherScanInfo(Map<String, Object> map) {
          // Airsonic scan statistics
          MediaLibraryStatistics stats = indexManager.getStatistics();
          if (stats != null) {
              map.put("statAlbumCount", stats.getAlbumCount());
              map.put("statArtistCount", stats.getArtistCount());
              map.put("statSongCount", stats.getSongCount());
              map.put("statLastScanDate", stats.getScanDate());
              map.put("statTotalDurationSeconds", stats.getTotalDurationInSeconds());
              map.put("statTotalLengthBytes", FileUtils.byteCountToDisplaySize(stats.getTotalLengthInBytes()));
          }
      }
  
      private void gatherIndexInfo(Map<String, Object> map) {
          SortedMap<String, IndexStatistics> indexStats = new TreeMap<>();
          for (IndexType indexType : IndexType.values()) {
              IndexStatistics stat = new IndexStatistics();
              IndexSearcher searcher = indexManager.getSearcher(indexType);
              stat.setName(indexType.name());
              indexStats.put(indexType.name(), stat);
              if (searcher != null) {
                  IndexReader reader = searcher.getIndexReader();
                  stat.setCount(reader.numDocs());
                  stat.setDeletedCount(reader.numDeletedDocs());
                  indexManager.release(indexType, searcher);
              } else {
                  stat.setCount(0);
                  stat.setDeletedCount(0);
              }
          }
          map.put("indexStatistics", indexStats);
  
          try (Analyzer analyzer = analyzerFactory.getAnalyzer()) {
              map.put("indexLuceneVersion", analyzer.getVersion().toString());
          } catch (IOException e) {
              LOG.debug("Unable to gather information", e);
          }
      }
  
      /**
       * Returns true if a locale string (e.g. en_US.UTF-8) appears to support UTF-8 correctly.
       *
       * Some systems use non-standard locales (e.g. en_US.utf8 instead of en_US.UTF-8)
       * to specify Unicode support, which are usually supported by the Glibc.
       *
       * See: https://superuser.com/questions/999133/differences-between-en-us-utf8-and-en-us-utf-8
       */
      private boolean doesLocaleSupportUtf8(String locale) {
          if (locale == null) {
              return false;
          } else {
              return locale.toLowerCase().replaceAll("\\W", "").contains("utf8");
          }
      }
  
      private void gatherLocaleInfo(Map<String, Object> map) {
          map.put("localeDefault", Locale.getDefault());
          map.put("localeUserLanguage", System.getProperty("user.language"));
          map.put("localeUserCountry", System.getProperty("user.country"));
          map.put("localeFileEncoding", System.getProperty("file.encoding"));
          map.put("localeSunJnuEncoding", System.getProperty("sun.jnu.encoding"));
          map.put("localeSunIoUnicodeEncoding", System.getProperty("sun.io.unicode.encoding"));
          map.put("localeLang", System.getenv("LANG"));
          map.put("localeLcAll", System.getenv("LC_ALL"));
          map.put("localeDefaultCharset", Charset.defaultCharset().toString());
  
          map.put("localeFileEncodingSupportsUtf8", doesLocaleSupportUtf8(System.getProperty("file.encoding")));
          map.put("localeLangSupportsUtf8", doesLocaleSupportUtf8(System.getenv("LANG")));
          map.put("localeLcAllSupportsUtf8", doesLocaleSupportUtf8(System.getenv("LC_ALL")));
          map.put("localeDefaultCharsetSupportsUtf8", doesLocaleSupportUtf8(Charset.defaultCharset().toString()));
      }
  
      private void gatherDatabaseInfo(Map<String, Object> map) {
  
          try (Connection conn = daoHelper.getDataSource().getConnection()) {
  
              // Driver name/version
              map.put("dbDriverName", conn.getMetaData().getDriverName());
              map.put("dbDriverVersion", conn.getMetaData().getDriverVersion());
              map.put("dbServerVersion", conn.getMetaData().getDatabaseProductVersion());
  
              // Gather information for existing database tables
              ResultSet resultSet = conn.getMetaData().getTables(null, null, "%", null);
              SortedMap<String, Long> dbTableCount = new TreeMap<>();
              while (resultSet.next()) {
                  String tableSchema = resultSet.getString("TABLE_SCHEM");
                  String tableName = resultSet.getString("TABLE_NAME");
                  String tableType = resultSet.getString("TABLE_TYPE");
                  LOG.debug("Got database table {}, schema {}, type {}", tableName, tableSchema, tableType);
                  if (!"table".equalsIgnoreCase(tableType)) continue;   // Table type
                  // MariaDB has "null" schemas, while other databases use "public".
                  if (tableSchema != null && !"public".equalsIgnoreCase(tableSchema)) continue;  // Table schema
                  try {
                      Long tableCount = daoHelper.getJdbcTemplate().queryForObject(String.format("SELECT count(*) FROM %s", tableName), Long.class);
                      dbTableCount.put(tableName, tableCount);
                  } catch (Exception e) {
                      LOG.debug("Unable to gather information", e);
                  }
              }
              map.put("dbTableCount", dbTableCount);
  
          } catch (SQLException e) {
              LOG.debug("Unable to gather information", e);
          }
  
          if (environment.acceptsProfiles("legacy")) {
              map.put("dbIsLegacy", true);
              File dbDirectory = new File(SettingsService.getAirsonicHome(), "db");
              map.put("dbDirectorySizeBytes", dbDirectory.exists() ? FileUtils.sizeOfDirectory(dbDirectory) : 0);
              map.put("dbDirectorySize", FileUtils.byteCountToDisplaySize((long) map.get("dbDirectorySizeBytes")));
              File dbLogFile = new File(dbDirectory, "airsonic.log");
              map.put("dbLogSizeBytes", dbLogFile.exists() ? dbLogFile.length() : 0);
              map.put("dbLogSize", FileUtils.byteCountToDisplaySize((long) map.get("dbLogSizeBytes")));
          } else {
              map.put("dbIsLegacy", false);
          }
  
          map.put("dbMediaFileMusicNonPresentCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(*) FROM media_file WHERE NOT present AND type = 'MUSIC'", Long.class));
          map.put("dbMediaFilePodcastNonPresentCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(*) FROM media_file WHERE NOT present AND type = 'PODCAST'", Long.class));
          map.put("dbMediaFileDirectoryNonPresentCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(*) FROM media_file WHERE NOT present AND type = 'DIRECTORY'", Long.class));
          map.put("dbMediaFileAlbumNonPresentCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(*) FROM media_file wheRE NOT present AND type = 'ALBUM'", Long.class));
  
          map.put("dbMediaFileMusicPresentCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(*) FROM media_file WHERE present AND type = 'MUSIC'", Long.class));
          map.put("dbMediaFilePodcastPresentCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(*) FROM media_file WHERE present AND type = 'PODCAST'", Long.class));
          map.put("dbMediaFileDirectoryPresentCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(*) FROM media_file WHERE present AND type = 'DIRECTORY'", Long.class));
          map.put("dbMediaFileAlbumPresentCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(*) FROM media_file WHERE present AND type = 'ALBUM'", Long.class));
  
          map.put("dbMediaFileDistinctAlbumCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(DISTINCT album) FROM media_file WHERE present", Long.class));
          map.put("dbMediaFileDistinctArtistCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(DISTINCT artist) FROM media_file WHERE present", Long.class));
          map.put("dbMediaFileDistinctAlbumArtistCount", daoHelper.getJdbcTemplate().queryForObject("SELECT count(DISTINCT album_artist) FROM media_file WHERE present", Long.class));
  
          map.put("dbMediaFilesInNonPresentMusicFoldersCount", mediaFileDao.getFilesInNonPresentMusicFoldersCount(Arrays.asList(settingsService.getPodcastFolder())));
          map.put("dbMediaFilesInNonPresentMusicFoldersSample", mediaFileDao.getFilesInNonPresentMusicFolders(10, Arrays.asList(settingsService.getPodcastFolder())));
  
          map.put("dbMediaFilesWithMusicFolderMismatchCount", mediaFileDao.getFilesWithMusicFolderMismatchCount());
          map.put("dbMediaFilesWithMusicFolderMismatchSample", mediaFileDao.getFilesWithMusicFolderMismatch(10));
      }
  
      private void gatherFilesystemInfo(Map<String, Object> map) {
          map.put("fsHomeDirectorySizeBytes", FileUtils.sizeOfDirectory(SettingsService.getAirsonicHome()));
          map.put("fsHomeDirectorySize", FileUtils.byteCountToDisplaySize((long)map.get("fsHomeDirectorySizeBytes")));
          map.put("fsHomeTotalSpaceBytes", SettingsService.getAirsonicHome().getTotalSpace());
          map.put("fsHomeTotalSpace", FileUtils.byteCountToDisplaySize((long)map.get("fsHomeTotalSpaceBytes")));
          map.put("fsHomeUsableSpaceBytes", SettingsService.getAirsonicHome().getUsableSpace());
          map.put("fsHomeUsableSpace", FileUtils.byteCountToDisplaySize((long)map.get("fsHomeUsableSpaceBytes")));
          SortedMap<String, FileStatistics> fsMusicFolderStatistics = new TreeMap<>();
          for (MusicFolder folder: musicFolderDao.getAllMusicFolders()) {
              FileStatistics stat = new FileStatistics();
              stat.setFromFile(folder.getPath());
              stat.setName(folder.getName());
              fsMusicFolderStatistics.put(folder.getName(), stat);
          }
          map.put("fsMusicFolderStatistics", fsMusicFolderStatistics);
      }
  
      private void gatherTranscodingInfo(Map<String, Object> map) {
          map.put("fsFfprobeInfo", gatherStatisticsForTranscodingExecutable("ffprobe"));
          map.put("fsFfmpegInfo", gatherStatisticsForTranscodingExecutable("ffmpeg"));
      }
  
      private static List<String> getLatestLogEntries(File logFile) {
          List<String> lines = new ArrayList<>(LOG_LINES_TO_SHOW);
          try (ReversedLinesFileReader reader = new ReversedLinesFileReader(logFile, Charset.defaultCharset())) {
              String current;
              while ((current = reader.readLine()) != null) {
                  if (lines.size() >= LOG_LINES_TO_SHOW) {
                      break;
                  }
                  lines.add(0, current);
              }
              return lines;
          } catch (IOException e) {
              LOG.warn("Could not open log file " + logFile, e);
              return null;
          }
      }
  
      private File lookForExecutable(String executableName) {
          for (String path : System.getenv("PATH").split(File.pathSeparator)) {
              File file = new File(path, executableName);
              if (file.exists()) {
                  LOG.debug("Found {} in {}", executableName, path);
                  return file;
              } else {
                  LOG.debug("Looking for {} in {} (not found)", executableName, path);
              }
          }
          return null;
      }
  
      private File lookForTranscodingExecutable(String executableName) {
          File executableLocation = null;
          for (String name: Arrays.asList(executableName, String.format("%s.exe", executableName))) {
              executableLocation = new File(transcodingService.getTranscodeDirectory(), name);
              if (executableLocation != null && executableLocation.exists()) return executableLocation;
              executableLocation = lookForExecutable(executableName);
              if (executableLocation != null && executableLocation.exists()) return executableLocation;
          }
          return null;
      }
  
      private FileStatistics gatherStatisticsForTranscodingExecutable(String executableName) {
          FileStatistics executableStatistics = null;
          File executableLocation = lookForTranscodingExecutable(executableName);
          if (executableLocation != null) {
              executableStatistics = new FileStatistics();
              executableStatistics.setFromFile(executableLocation);
          }
          return executableStatistics;
      }
  
  }
  