Source code for musicdb.lib.fileprocessing
# MusicDB, a music manager with web-bases UI that focus on music.
# Copyright (C) 2021 Ralf Stemmer <ralf.stemmer@gmx.net>
#
# This program 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.
#
# This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
import os
import shutil
import logging
import subprocess
import hashlib
from musicdb.lib.filesystem import Filesystem
[docs]class Fileprocessing(Filesystem):
"""
This class extends the interface to the :class:`musicdb.lib.filesystem.Filesystem` class.
It provides file processing methods.
These methods may execute other linux shell commands.
.. graphviz::
digraph hierarchy {
size="5,5"
node[shape=record,style=filled,fillcolor=gray95]
edge[dir=back, arrowtail=empty]
filesystem [label = "{Filesystem||}"]
fileprocessing [label = "{Fileprocessing||}"]
filesystem -> fileprocessing
}
Whenever I write about *root director* the path set in this class as root is meant.
Otherwise I would call it *system root directory*.
All paths of the methods provided by this class can be absolute or relative.
The paths of methods from :class:`musicdb.lib.filesystem.Filesystem` must be in the format as defined for that class.
Args:
root(str): Path to the internal used root directory. It is allowd to start with "./".
Raises:
ValueError: if the root path does not start with ``"/"`` or does not exist
"""
def __init__(self, root="/"):
Filesystem.__init__(self, root)
[docs] def ExistsProgram(self, programname: str) -> bool:
"""
This method checks if a program exists.
Example:
.. code-block:: python
if fp.ExistsProgram("ffmpeg") == False:
print("ffmpeg is not installed!");
Args:
programname (str): Name of the program to check for
Returns:
``True`` if the program exists, otherwise ``False``
"""
path = shutil.which(programname)
if path == None:
return False
return True
[docs] def Checksum(self, path, algorithm="sha256"):
"""
This method calculates the checksum of a file and returns it hexadecimal encoded as a string.
If the file does not exists, ``None`` gets returned.
The default algorithm is SHA-256
Args:
path (str): Path to the file from that the checksum shall be calculated
algorithm (str): Checksum algorithm: ``"sha256"``, ``"sha1"``
Returns:
Checksum as string, or ``None`` if the file does not exist.
Raises:
ValueError: For unknown algorithm argument.
Example:
.. code-block:: python
checksum = fs.Checksum(song["path"])
print("Checksum: %s" % (checksum))
"""
if algorithm not in ["sha256", "sha1"]:
raise ValueError("algorithm must be \"sha256\" or \"sha1\"")
abspath = self.AbsolutePath(path)
if not self.IsFile(abspath):
return None
with open(abspath, "rb") as f:
if algorithm == "sha256":
checksum = hashlib.sha256(f.read()).hexdigest()
elif algorithm == "sha1":
checksum = hashlib.sha1(f.read()).hexdigest()
return checksum
[docs] def ConvertToMP3(self, srcpath, dstpath):
"""
This method converts any audio file format to an mp3 file using ``ffmpeg``.
The encoder is *libmp3lame* and the bitrate is esoteric 320kbit/s.
If the destination file exists, it will be overwritten.
Source and destination path must be different.
This method corresponds to the following command line:
.. code-block:: bash
ffmpeg -v quiet -y -i $abssrcpath -acodec libmp3lame -ab 320k $absdstpath < /dev/null > /dev/null 2>&1
.. warning::
Call ``os.sync`` if the generated file will be further processed.
In the past, there were lots of trouble with "incomplete" files.
Args:
srcpath (str): source path of a song file with any encoding
dstpath (str): destination path of the new generated song file with mp3 encoding.
Returns:
``True`` on success, otherwise ``False``
Raises:
ValueError: When ``srcpath`` and ``dstpath`` address the same file
"""
abssrcpath = self.AbsolutePath(srcpath)
absdstpath = self.AbsolutePath(dstpath)
if abssrcpath == absdstpath:
logging.error("Source (%s) and Destination (%s) address the same file!", abssrcpath, absdstpath)
raise ValueError("Source and Destination path address the same file!")
if not self.ExistsProgram("ffmpeg"):
logging.warning("Optional dependency \"ffmpeg\" missing. Cannot convert %s to %s!", abssrcpath, absdstpath)
return False
logging.debug("Converting %s to %s …", abssrcpath, absdstpath)
process =[
"ffmpeg",
"-v", "quiet", # do not be verbose
"-y", # overwrite
"-i", abssrcpath,
"-acodec", "libmp3lame", "-ab", "320k",
absdstpath]
try:
self.Execute(process)
except Exception as e:
logging.error("Error \"%s\" while executing: %s", str(e), str(process))
return False
return True
[docs] def OptimizeMP3Tags(self, mdbsong, mdbalbum, mdbartist, srcpath, dstpath, absartworkpath=None, forceID3v230=False):
"""
This method fixed the ID3 tags of an mp3 file.
For writing the new ID3 tags, ``id3edit`` is used.
Source and destination path can be the same.
The artwork path must be absolute!
The call of ``id3edit`` corresponds to the following command line, reduced to just setting the songname.
.. code-block:: bash
id3edit --clear --create --set-name "Name of the Song" --outfile $absdstpath $abssrcpath < /dev/null > /dev/null 2>&1
The following tags will be set. All other will be removed.
* Song name
* Album name
* Artist name
* Release date
* Track number
* CD number
* Artwork
.. warning::
All other tags will not be copied to the new file.
The ``id3edit`` call creates a totaly new *ID3v2* header for the mp3 file.
Args:
mdbsong: The corresponding song-object from the MusicDB Database
mdbalbum: The corresponding album-object from the MusicDB Database
mdbartist: The corresponding artist-object from the MusicDB Database
srcpath (str): absolute source path of the mp3-file (must be mp3!)
dstpath (str): absolute destination path for the new mp3-file. (Can be the same as the source path)
absartworkpath (str): if not ``None`` the album artwork will be stored inside the ID3 tags. Otherwise it will be removed.
forceID3v230 (bool): use old ID3v2.3.0 tags instead of modern 2.4.0. Some player don't like a version number other than 2.3.0.
Returns:
``True`` on success, otherwise ``False``
Raises:
ValueError: When the source or destination path is not an mp3 file.
"""
abssrcpath = self.AbsolutePath(srcpath)
absdstpath = self.AbsolutePath(dstpath)
logging.debug("Optimize %s to %s for songid=%d, artwork=%s, prescale=%s, forceID3v230=%s",
abssrcpath, absdstpath, int(mdbsong["id"]), str(absartworkpath), str("500x500"), str(forceID3v230))
# Check if paths are mp3-files! - caused a crash at least one times
if self.GetFileExtension(abssrcpath) != "mp3":
logging.error("%s is not a mp3-file and cannot be optimized by id3edit!", str(abssrcpath))
raise ValueError("srcpath is not a mp3-file")
if self.GetFileExtension(absdstpath) != "mp3":
logging.error("%s is not a mp3-file. Output of id3edit is an mp3-file!", str(absdstpath))
raise ValueError("dstpath is not a mp3-file")
if not self.ExistsProgram("id3edit"):
logging.warning("Optional dependency \"id3edit\" missing. Cannot convert %s to %s!", abssrcpath, absdstpath)
return False
# Create tags
songname = mdbsong["name"]
albumname = mdbalbum["name"]
artistname = mdbartist["name"]
release = str(mdbalbum["release"])
track = "%02d/%02d" % (mdbsong["number"], mdbalbum["numofsongs"])
cd = "%1d/%1d" % (mdbsong["cd"], mdbalbum["numofcds"])
# start optimizing the tags - at the same time the file gets copied to the new place
process = ["id3edit", "--clear", "--create"]
if forceID3v230:
process += ["--force230"]
process += [
"--set-name", songname,
"--set-album", albumname,
"--set-artist", artistname,
"--set-release", release,
"--set-track", track,
"--set-cd", cd
]
if absartworkpath:
if not self.IsFile(absartworkpath):
logging.error("Artwork \"%s\" does not exist but was expected to exist!", artworkpath)
return False
process += ["--set-artwork", absartworkpath]
process += ["--outfile", absdstpath, abssrcpath]
try:
self.Execute(process)
except Exception as e:
logging.error("Error \"%s\" while executing: %s", str(e), str(process))
return False
return True
[docs] def OptimizeM4ATags(self, mdbsong, mdbalbum, mdbartist, srcpath, dstpath):
"""
This method fixes the Tags of an m4a file.
The data for the Tags come from the MusicDB Database.
For writing the new tags, ``ffmpeg`` is used.
Source and destination path must be different.
The call of ``ffmpeg`` corresponds to the following command line that got condensed to just setting the songname.
.. code-block:: bash
ffmpeg -v quiet -y -i $abssrcpath -vn -acodec copy -metadata title="Name of the Song" $absdstpath < /dev/null > /dev/null 2>&1
The following tags will be set:
* Song name
* Album name
* Artist name
* Release date
* Track number
* CD number
.. warning::
Other tags could be removed - I don't care about them.
If ``ffmpeg`` also doesn't care, they are lost.
This will definitely happen with iTunes related tags like the Apple ID and purchase-date.
**Artwork will be removed, too** :( - This is considered as a bug. I just have no solution to fix it right now.
The following code should preserve the artwork but throws an error:
``ffmpeg -y -i src.m4a -map 0:0 -map 0:1 -vcodec copy -acodec copy -metadata "title=Title" dst.m4a``
Source and destination file must be different.
The source file must have the file extension ``.m4a`` or ``.mp4``.
Args:
mdbsong: The corresponding song-object from the MusicDB Database
mdbalbum: The corresponding album-object from the MusicDB Database
mdbartist: The corresponding artist-object from the MusicDB Database
srcpath (str): source path of the m4a-file (must be m4a!)
dstpath (str): destination path for the new m4a-file. (Must be different from the source path)
Returns:
``True`` on success, otherwise ``False``
"""
abssrcpath = self.AbsolutePath(srcpath)
absdstpath = self.AbsolutePath(dstpath)
if abssrcpath == absdstpath:
logging.error("Source (%s) and Destination (%s) address the same file!", abssrcpath, absdstpath)
raise ValueError("Source and Destination path address the same file!")
logging.debug("Optimize %s to %s for songid=%d",
abssrcpath, absdstpath, int(mdbsong["id"]))
# Check if paths are valid
if self.GetFileExtension(abssrcpath) not in ["m4a","mp4"]:
logging.error("%s is not a m4a-file and cannot be optimized by this function - Check said song is of type %s", str(abssrcpath), str(os.path.splitext(abssrcpath)[1]))
return False
if abssrcpath == absdstpath:
logging.error("source and destination paths are the same. This is not supported by this function.")
return False
if not self.ExistsProgram("ffmpeg"):
logging.warning("Optional dependency \"ffmpeg\" missing. Cannot convert %s to %s!", abssrcpath, absdstpath)
return False
# Create tags
songname = mdbsong["name"]
albumname = mdbalbum["name"]
artistname = mdbartist["name"]
release = str(mdbalbum["release"])
track = "%02d/%02d" % (mdbsong["number"], mdbalbum["numofsongs"])
cd = "%1d" % (mdbsong["cd"])
# start optimizing the tags - at the same time the file gets copied to the new place
process = [
"ffmpeg", "-v", "quiet",
"-y",
"-i", abssrcpath,
"-vn",
"-acodec", "copy",
"-metadata", "title=" + songname ,
"-metadata", "album=" + albumname ,
"-metadata", "author=" + artistname,
"-metadata", "year=" + release ,
"-metadata", "track=" + track ,
"-metadata", "disc=" + cd ,
absdstpath
]
try:
self.Execute(process)
except Exception as e:
logging.error("Error \"%s\" while executing: %s", str(e), str(process))
return False
return True
# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4