Permalink
Fetching contributors…
Cannot retrieve contributors at this time
914 lines (775 sloc) 26.6 KB
#include <QDir>
#include <QFile>
#include <QRegExp>
#include <QUrl>
#include "storagegroup.h"
#include "mythcorecontext.h"
#include "mythdb.h"
#include "mythlogging.h"
#include "mythcoreutil.h"
#include "mythdirs.h"
#define LOC QString("SG(%1): ").arg(m_groupname)
const char *StorageGroup::kDefaultStorageDir = "/mnt/store";
QMutex StorageGroup::m_staticInitLock;
bool StorageGroup::m_staticInitDone = false;
QMap<QString, QString> StorageGroup::m_builtinGroups;
QMutex StorageGroup::s_groupToUseLock;
QHash<QString,QString> StorageGroup::s_groupToUseCache;
const QStringList StorageGroup::kSpecialGroups = QStringList()
<< QT_TRANSLATE_NOOP("(StorageGroups)", "LiveTV")
// << "Thumbnails"
<< QT_TRANSLATE_NOOP("(StorageGroups)", "DB Backups")
<< QT_TRANSLATE_NOOP("(StorageGroups)", "Videos")
<< QT_TRANSLATE_NOOP("(StorageGroups)", "Trailers")
<< QT_TRANSLATE_NOOP("(StorageGroups)", "Coverart")
<< QT_TRANSLATE_NOOP("(StorageGroups)", "Fanart")
<< QT_TRANSLATE_NOOP("(StorageGroups)", "Screenshots")
<< QT_TRANSLATE_NOOP("(StorageGroups)", "Banners")
<< QT_TRANSLATE_NOOP("(StorageGroups)", "Photographs")
<< QT_TRANSLATE_NOOP("(StorageGroups)", "Music")
<< QT_TRANSLATE_NOOP("(StorageGroups)", "MusicArt")
;
/****************************************************************************/
/** \brief StorageGroup constructor.
* \param group storage group to search, blank will search all groups.
* \param hostname hostname where to search, blank will search all hosts'
* directories, but only in local directory structure.
* This is parameter is ignored if group is an empty string.
* \param allowFallback allow the storage group search code to fall back if
* no dirs exist for the specified group/host
*/
StorageGroup::StorageGroup(const QString &group, const QString &hostname,
bool allowFallback) :
m_groupname(group), m_hostname(hostname), m_allowFallback(allowFallback)
{
m_groupname.detach();
m_hostname.detach();
m_dirlist.clear();
if (getenv("MYTHTV_NOSGFALLBACK"))
m_allowFallback = false;
Init(m_groupname, m_hostname, m_allowFallback);
}
void StorageGroup::StaticInit(void)
{
QMutexLocker locker(&m_staticInitLock);
if (m_staticInitDone)
return;
m_staticInitDone = true;
m_builtinGroups["ChannelIcons"] = GetConfDir() + "/channels";
m_builtinGroups["Themes"] = GetConfDir() + "/themes";
m_builtinGroups["Temp"] = GetConfDir() + "/tmp";
m_builtinGroups["Streaming"] = GetConfDir() + "/tmp/hls";
m_builtinGroups["3rdParty"] = GetConfDir() + "/3rdParty";
QMap<QString, QString>::iterator it = m_builtinGroups.begin();
for (; it != m_builtinGroups.end(); ++it)
{
QDir qdir(it.value());
if (!qdir.exists())
qdir.mkpath(it.value());
if (!qdir.exists())
LOG(VB_GENERAL, LOG_ERR,
QString("SG() Error: Could not create builtin"
"Storage Group directory '%1' for '%2'").arg(it.value())
.arg(it.key()));
}
}
/**
* \brief Initilizes the groupname, hostname, and dirlist
*
* First attempts to find the Storage Group defined with the specified name
* for the given host. If not found, checks for the named Storage Group, as
* defined across all hosts. If not found, tries the "Default" Storage Group
* for the given host. If not found, tries the "Default" Storage Group, as
* defined across all hosts.
*
* \param group The name of the Storage Group
* \param hostname The host whose Storage Group definition is desired
* \param allowFallback allow the storage group search code to fall back if
* no dirs exist for the specified group/host
*/
void StorageGroup::Init(const QString &group, const QString &hostname,
const bool allowFallback)
{
bool found = false;
m_groupname = group; m_groupname.detach();
m_hostname = hostname; m_hostname.detach();
m_allowFallback = allowFallback;
m_dirlist.clear();
StaticInit();
found = FindDirs(m_groupname, m_hostname, &m_dirlist);
if (!found && m_builtinGroups.contains(group))
{
QDir testdir(m_builtinGroups[group]);
if (!testdir.exists())
testdir.mkpath(m_builtinGroups[group]);
if (testdir.exists())
{
m_dirlist.prepend(testdir.absolutePath());
found = true;
}
}
if ((!found) && m_allowFallback && (m_groupname != "LiveTV") &&
(!hostname.isEmpty()))
{
LOG(VB_FILE, LOG_NOTICE, LOC +
QString("Unable to find any directories for the local "
"storage group '%1' on '%2', trying directories on "
"all hosts!").arg(group).arg(hostname));
found = FindDirs(m_groupname, "", &m_dirlist);
if (found)
{
m_hostname = "";
m_hostname.detach();
}
}
if ((!found) && m_allowFallback && (group != "Default"))
{
LOG(VB_FILE, LOG_NOTICE, LOC +
QString("Unable to find storage group '%1', trying "
"'Default' group!").arg(group));
found = FindDirs("Default", m_hostname, &m_dirlist);
if(found)
{
m_groupname = "Default";
m_groupname.detach();
}
else if (!hostname.isEmpty())
{
LOG(VB_FILE, LOG_NOTICE, LOC +
QString("Unable to find any directories for the local "
"Default storage group on '%1', trying directories "
"in all Default groups!").arg(hostname));
found = FindDirs("Default", "", &m_dirlist);
if(found)
{
m_groupname = "Default";
m_hostname = "";
m_groupname.detach();
m_hostname.detach();
}
}
}
if (allowFallback && !m_dirlist.size())
{
QString msg = "Unable to find any Storage Group Directories. ";
QString tmpDir = gCoreContext->GetSetting("RecordFilePrefix");
if (tmpDir != "")
{
msg += QString("Using old 'RecordFilePrefix' value of '%1'")
.arg(tmpDir);
}
else
{
tmpDir = kDefaultStorageDir;
msg += QString("Using hardcoded default value of '%1'")
.arg(kDefaultStorageDir);
}
LOG(VB_GENERAL, LOG_ERR, LOC + msg);
m_dirlist << tmpDir;
}
}
QString StorageGroup::GetFirstDir(bool appendSlash) const
{
if (m_dirlist.isEmpty())
return QString();
QString tmp = m_dirlist[0];
tmp.detach();
if (appendSlash)
tmp += "/";
return tmp;
}
QStringList StorageGroup::GetDirFileList(const QString &dir,
const QString &lbase,
bool recursive, bool onlyDirs)
{
QStringList files;
QString base = lbase;
QDir d(dir);
if (!d.exists())
return files;
if (base.split("/").size() > 20)
{
LOG(VB_GENERAL, LOG_ERR, LOC + "GetDirFileList(), 20 levels deep, "
"possible directory loop detected.");
return files;
}
if (!base.isEmpty())
base += "/";
if (recursive)
{
QStringList list =
d.entryList(QDir::Dirs|QDir::NoDotAndDotDot|QDir::Readable);
for (QStringList::iterator p = list.begin(); p != list.end(); ++p)
{
LOG(VB_FILE, LOG_DEBUG, LOC +
QString("GetDirFileList: Dir: %1/%2").arg(base).arg(*p));
if (onlyDirs)
files.append(base + *p);
files << GetDirFileList(dir + "/" + *p, base + *p, true, onlyDirs);
}
}
if (!onlyDirs)
{
QStringList list = d.entryList(QDir::Files|QDir::Readable);
for (QStringList::iterator p = list.begin(); p != list.end(); ++p)
{
LOG(VB_FILE, LOG_DEBUG, LOC +
QString("GetDirFileList: File: %1%2").arg(base).arg(*p));
if (recursive)
files.append(base + *p);
else
files.append(*p);
}
}
return files;
}
QStringList StorageGroup::GetDirList(const QString &Path, bool recursive)
{
QStringList files;
QString tmpDir;
QDir d;
for (QStringList::Iterator it = m_dirlist.begin(); it != m_dirlist.end(); ++it)
{
tmpDir = *it + Path;
d.setPath(tmpDir);
if (d.exists())
files << GetDirFileList(tmpDir, Path, recursive, true);
}
return files;
}
QStringList StorageGroup::GetFileList(const QString &Path, bool recursive)
{
QStringList files;
QString tmpDir;
QDir d;
for (QStringList::Iterator it = m_dirlist.begin(); it != m_dirlist.end(); ++it)
{
tmpDir = *it + Path;
d.setPath(tmpDir);
if (d.exists())
files << GetDirFileList(tmpDir, Path, recursive, false);
}
return files;
}
QStringList StorageGroup::GetFileInfoList(const QString &Path)
{
QStringList files;
QString relPath;
bool badPath = true;
if (Path.isEmpty() || Path == "/")
{
for (QStringList::Iterator it = m_dirlist.begin(); it != m_dirlist.end(); ++it)
files << QString("sgdir::%1").arg(*it);
return files;
}
for (QStringList::Iterator it = m_dirlist.begin(); it != m_dirlist.end(); ++it)
{
if (Path.startsWith(*it))
{
relPath = Path;
relPath.replace(*it,"");
if (relPath.startsWith("/"))
relPath.replace(0,1,"");
badPath = false;
}
}
LOG(VB_FILE, LOG_INFO, LOC +
QString("GetFileInfoList: Reading '%1'").arg(Path));
if (badPath)
return files;
QDir d(Path);
if (!d.exists())
return files;
d.setFilter(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot);
QFileInfoList list = d.entryInfoList();
if (list.isEmpty())
return files;
for (QFileInfoList::iterator p = list.begin(); p != list.end(); ++p)
{
if (p->fileName() == "Thumbs.db")
continue;
QString tmp;
if (p->isDir())
tmp = QString("dir::%1::0").arg(p->fileName());
else
tmp = QString("file::%1::%2::%3%4").arg(p->fileName()).arg(p->size())
.arg(relPath).arg(p->fileName());
LOG(VB_FILE, LOG_DEBUG, LOC +
QString("GetFileInfoList: (%1)").arg(tmp));
files.append(tmp);
}
return files;
}
bool StorageGroup::FileExists(const QString &filename)
{
LOG(VB_FILE, LOG_DEBUG, LOC +
QString("FileExist: Testing for '%1'").arg(filename));
bool badPath = true;
if (filename.isEmpty())
return false;
for (QStringList::Iterator it = m_dirlist.begin(); it != m_dirlist.end(); ++it)
{
if (filename.startsWith(*it))
{
badPath = false;
}
}
if (badPath)
return false;
bool result = false;
QFile checkFile(filename);
if (checkFile.exists(filename))
result = true;
return result;
}
// Returns a string list of details about the file
// in the order EXISTS, DATE, SIZE
QStringList StorageGroup::GetFileInfo(const QString &lfilename)
{
QString filename = lfilename;
LOG(VB_FILE, LOG_DEBUG, LOC +
QString("GetFileInfo: For '%1'") .arg(filename));
QStringList details;
bool searched = false;
if (!FileExists(filename))
{
searched = true;
filename = FindFile(filename);
}
if ((searched && !filename.isEmpty()) ||
(FileExists(filename)))
{
QFileInfo fInfo(filename);
details << filename;
details << QString("%1").arg(fInfo.lastModified().toTime_t());
details << QString("%1").arg(fInfo.size());
}
return details;
}
/**
* \brief Returns the relative pathname of a file by comparing the filename
* against all Storage Group directories (and MythVideo's startupdir)
*
* \param filename The full pathname of the file to use
* \return The relative path if it can be determined, otherwise the
* full input filename is returned back to the caller.
*/
QString StorageGroup::GetRelativePathname(const QString &filename)
{
QString result = filename;
MSqlQuery query(MSqlQuery::InitCon());
LOG(VB_FILE, LOG_DEBUG,
QString("StorageGroup::GetRelativePathname(%1)").arg(filename));
StaticInit();
if (filename.startsWith("myth://"))
{
QUrl qurl(filename);
if (qurl.hasFragment())
result = qurl.path() + "#" + qurl.fragment();
else
result = qurl.path();
if (result.startsWith("/"))
result.replace(0, 1, "");
return result;
}
query.prepare("SELECT DISTINCT dirname FROM storagegroup "
"ORDER BY dirname DESC;");
if (query.exec())
{
QString dirname;
while (query.next())
{
/* The storagegroup.dirname column uses utf8_bin collation, so Qt
* uses QString::fromLatin1() for toString(). Explicitly convert the
* value using QString::fromUtf8() to prevent corruption. */
dirname = QString::fromUtf8(query.value(0)
.toByteArray().constData());
if (filename.startsWith(dirname))
{
result = filename;
result.replace(0, dirname.length(), "");
if (result.startsWith("/"))
result.replace(0, 1, "");
LOG(VB_FILE, LOG_DEBUG,
QString("StorageGroup::GetRelativePathname(%1) = '%2'")
.arg(filename).arg(result));
return result;
}
}
}
query.prepare("SELECT DISTINCT data FROM settings WHERE "
"value = 'VideoStartupDir';");
if (query.exec())
{
while (query.next())
{
QString videostartupdir = query.value(0).toString();
QStringList videodirs = videostartupdir.split(':',
QString::SkipEmptyParts);
QString directory;
for (QStringList::Iterator it = videodirs.begin();
it != videodirs.end(); ++it)
{
directory = *it;
if (filename.startsWith(directory))
{
result = filename;
result.replace(0, directory.length(), "");
if (result.startsWith("/"))
result.replace(0, 1, "");
LOG(VB_FILE, LOG_DEBUG,
QString("StorageGroup::GetRelativePathname(%1) = '%2'")
.arg(filename).arg(result));
return result;
}
}
}
}
QMap<QString, QString>::iterator it = m_builtinGroups.begin();
for (; it != m_builtinGroups.end(); ++it)
{
QDir qdir(it.value());
if (!qdir.exists())
qdir.mkpath(it.value());
QString directory = it.value();
if (filename.startsWith(directory))
{
result = filename;
result.replace(0, directory.length(), "");
if (result.startsWith("/"))
result.replace(0, 1, "");
LOG(VB_FILE, LOG_DEBUG,
QString("StorageGroup::GetRelativePathname(%1) = '%2'")
.arg(filename).arg(result));
return result;
}
}
return result;
}
/**
* \brief Finds and and optionally initialize a directory list
* associated with a Storage Group
*
* \param group The name of the Storage Group
* \param hostname The host whose directory list should be checked, first
* \param dirlist Optional pointer to a QStringList to hold found dir list
* \return true if directories were found
*/
bool StorageGroup::FindDirs(const QString &group, const QString &hostname,
QStringList *dirlist)
{
bool found = false;
QString dirname;
MSqlQuery query(MSqlQuery::InitCon());
StaticInit();
QString sql = "SELECT DISTINCT dirname "
"FROM storagegroup ";
if (!group.isEmpty())
{
sql.append("WHERE groupname = :GROUP");
if (!hostname.isEmpty())
sql.append(" AND hostname = :HOSTNAME");
}
query.prepare(sql);
if (!group.isEmpty())
{
query.bindValue(":GROUP", group);
if (!hostname.isEmpty())
query.bindValue(":HOSTNAME", hostname);
}
if (!query.exec() || !query.isActive())
MythDB::DBError("StorageGroup::StorageGroup()", query);
else if (query.next())
{
do
{
/* The storagegroup.dirname column uses utf8_bin collation, so Qt
* uses QString::fromLatin1() for toString(). Explicitly convert the
* value using QString::fromUtf8() to prevent corruption. */
dirname = QString::fromUtf8(query.value(0)
.toByteArray().constData());
dirname.replace(QRegExp("^\\s*"), "");
dirname.replace(QRegExp("\\s*$"), "");
if (dirname.endsWith("/"))
dirname.remove(dirname.length() - 1, 1);
if (dirlist)
(*dirlist) << dirname;
else
return true;
}
while (query.next());
found = true;
}
if (m_builtinGroups.contains(group))
{
QDir testdir(m_builtinGroups[group]);
if (testdir.exists())
{
if (dirlist && !dirlist->contains(testdir.absolutePath()))
(*dirlist) << testdir.absolutePath();
found = true;
}
}
return found;
}
QString StorageGroup::FindFile(const QString &filename)
{
LOG(VB_FILE, LOG_DEBUG, LOC + QString("FindFile: Searching for '%1'")
.arg(filename));
QString recDir = FindFileDir(filename);
QString result = "";
if (!recDir.isEmpty())
{
result = recDir + "/" + filename;
LOG(VB_FILE, LOG_DEBUG, LOC +
QString("FindFile: Found '%1'") .arg(result));
}
else
{
LOG(VB_FILE, LOG_ERR, LOC +
QString("FindFile: Unable to find '%1'!") .arg(filename));
}
return result;
}
QString StorageGroup::FindFileDir(const QString &filename)
{
QString result = "";
QFileInfo checkFile("");
int curDir = 0;
while (curDir < m_dirlist.size())
{
QString testFile = m_dirlist[curDir] + "/" + filename;
LOG(VB_FILE, LOG_DEBUG, LOC +
QString("FindFileDir: Checking '%1' for '%2'")
.arg(m_dirlist[curDir]).arg(testFile));
checkFile.setFile(testFile);
if (checkFile.exists() || checkFile.isSymLink())
{
QString tmp = m_dirlist[curDir];
tmp.detach();
return tmp;
}
curDir++;
}
if (m_groupname.isEmpty() || (m_allowFallback == false))
{
// Not found in any dir, so try RecordFilePrefix if it exists
QString tmpFile =
gCoreContext->GetSetting("RecordFilePrefix") + "/" + filename;
checkFile.setFile(tmpFile);
if (checkFile.exists() || checkFile.isSymLink())
result = tmpFile;
}
else if (m_groupname != "Default")
{
// Not found in current group so try Default
StorageGroup sgroup("Default");
QString tmpFile = sgroup.FindFileDir(filename);
result = (tmpFile.isEmpty()) ? result : tmpFile;
}
else
{
// Not found in Default so try any dir
StorageGroup sgroup;
QString tmpFile = sgroup.FindFileDir(filename);
result = (tmpFile.isEmpty()) ? result : tmpFile;
}
result.detach();
return result;
}
QString StorageGroup::FindNextDirMostFree(void)
{
QString nextDir;
int64_t nextDirFree = 0;
int64_t thisDirTotal;
int64_t thisDirUsed;
int64_t thisDirFree;
LOG(VB_FILE, LOG_DEBUG, LOC + QString("FindNextDirMostFree: Starting"));
if (m_allowFallback)
nextDir = kDefaultStorageDir;
if (m_dirlist.size())
nextDir = m_dirlist[0];
QDir checkDir("");
int curDir = 0;
while (curDir < m_dirlist.size())
{
checkDir.setPath(m_dirlist[curDir]);
if (!checkDir.exists())
{
LOG(VB_GENERAL, LOG_ERR, LOC +
QString("FindNextDirMostFree: '%1' does not exist!")
.arg(m_dirlist[curDir]));
curDir++;
continue;
}
thisDirFree = getDiskSpace(m_dirlist[curDir], thisDirTotal,
thisDirUsed);
LOG(VB_FILE, LOG_DEBUG, LOC +
QString("FindNextDirMostFree: '%1' has %2 KiB free")
.arg(m_dirlist[curDir])
.arg(QString::number(thisDirFree)));
if (thisDirFree > nextDirFree)
{
nextDir = m_dirlist[curDir];
nextDirFree = thisDirFree;
}
curDir++;
}
if (nextDir.isEmpty())
LOG(VB_FILE, LOG_ERR, LOC +
"FindNextDirMostFree: Unable to find any directories to use.");
else
LOG(VB_FILE, LOG_DEBUG, LOC +
QString("FindNextDirMostFree: Using '%1'").arg(nextDir));
nextDir.detach();
return nextDir;
}
void StorageGroup::CheckAllStorageGroupDirs(void)
{
QString m_groupname;
QString dirname;
MSqlQuery query(MSqlQuery::InitCon());
query.prepare("SELECT groupname, dirname "
"FROM storagegroup "
"WHERE hostname = :HOSTNAME;");
query.bindValue(":HOSTNAME", gCoreContext->GetHostName());
if (!query.exec() || !query.isActive())
{
MythDB::DBError("StorageGroup::CheckAllStorageGroupDirs()", query);
return;
}
LOG(VB_FILE, LOG_DEBUG, LOC +
"CheckAllStorageGroupDirs(): Checking All Storage Group directories");
QFile testFile("");
QDir testDir("");
while (query.next())
{
m_groupname = query.value(0).toString();
/* The storagegroup.dirname column uses utf8_bin collation, so Qt
* uses QString::fromLatin1() for toString(). Explicitly convert the
* value using QString::fromUtf8() to prevent corruption. */
dirname = QString::fromUtf8(query.value(1)
.toByteArray().constData());
dirname.replace(QRegExp("^\\s*"), "");
dirname.replace(QRegExp("\\s*$"), "");
LOG(VB_FILE, LOG_DEBUG, LOC +
QString("Checking directory '%1' in group '%2'.")
.arg(dirname).arg(m_groupname));
testDir.setPath(dirname);
if (!testDir.exists())
{
LOG(VB_FILE, LOG_WARNING, LOC +
QString("Group '%1' references directory '%2' but this "
"directory does not exist. This directory "
"will not be used on this server.")
.arg(m_groupname).arg(dirname));
}
else
{
testFile.setFileName(dirname + "/.test");
if (testFile.open(QIODevice::WriteOnly))
testFile.remove();
else
LOG(VB_GENERAL, LOG_ERR, LOC +
QString("Group '%1' wants to use directory '%2', but "
"this directory is not writeable.")
.arg(m_groupname).arg(dirname));
}
}
}
QStringList StorageGroup::getRecordingsGroups(void)
{
QStringList groups;
MSqlQuery query(MSqlQuery::InitCon());
QString sql = "SELECT DISTINCT groupname "
"FROM storagegroup "
"WHERE groupname NOT IN (";
for (QStringList::const_iterator it = StorageGroup::kSpecialGroups.begin();
it != StorageGroup::kSpecialGroups.end(); ++it)
sql.append(QString(" '%1',").arg(*it));
sql = sql.left(sql.length() - 1);
sql.append(" );");
query.prepare(sql);
if (query.exec())
{
while (query.next())
{
groups += query.value(0).toString();
}
}
groups.sort();
groups.detach();
return groups;
}
QStringList StorageGroup::getGroupDirs(const QString &groupname,
const QString &host)
{
QStringList groups;
QString addHost;
MSqlQuery query(MSqlQuery::InitCon());
if (!host.isEmpty())
addHost = " AND hostname = :HOSTNAME";
else
addHost = "";
QString sql = QString("SELECT dirname,hostname "
"FROM storagegroup "
"WHERE groupname = :GROUPNAME %1").arg(addHost);
query.prepare(sql);
query.bindValue(":GROUPNAME", groupname);
if (!host.isEmpty())
query.bindValue(":HOSTNAME", host);
if (query.exec())
{
QString dirname;
while (query.next())
{
/* The storagegroup.dirname column uses utf8_bin collation, so Qt
* uses QString::fromLatin1() for toString(). Explicitly convert the
* value using QString::fromUtf8() to prevent corruption. */
dirname = QString::fromUtf8(query.value(0)
.toByteArray().constData());
groups += gCoreContext->GenMythURL(query.value(1).toString(),
0,
dirname,
groupname);
}
}
groups.sort();
groups.detach();
return groups;
}
void StorageGroup::ClearGroupToUseCache(void)
{
QMutexLocker locker(&s_groupToUseLock);
s_groupToUseCache.clear();
}
QString StorageGroup::GetGroupToUse(
const QString &host, const QString &sgroup)
{
QString tmpGroup = sgroup;
QString groupKey = QString("%1:%2").arg(sgroup).arg(host);
QMutexLocker locker(&s_groupToUseLock);
if (s_groupToUseCache.contains(groupKey))
{
tmpGroup = s_groupToUseCache[groupKey];
}
else
{
if (StorageGroup::FindDirs(sgroup, host))
{
s_groupToUseCache[groupKey] = sgroup;
}
else
{
LOG(VB_FILE, LOG_DEBUG,
QString("GetGroupToUse(): "
"falling back to Videos Storage Group for host %1 "
"since it does not have a %2 Storage Group.")
.arg(host).arg(sgroup));
tmpGroup = "Videos";
s_groupToUseCache[groupKey] = tmpGroup;
}
}
return tmpGroup;
}
/* vim: set expandtab tabstop=4 shiftwidth=4: */