mirror of
https://github.com/rembo10/headphones.git
synced 2026-01-09 14:58:05 -05:00
-include soulseek results in overall search results -fixed windows paths not quite working on macOS -allow user search term -tighten search for self titled, Various Artists -postprocess by user/folder -bit more logging
1425 lines
58 KiB
Python
Executable File
1425 lines
58 KiB
Python
Executable File
# This file is part of Headphones.
|
|
#
|
|
# Headphones 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.
|
|
#
|
|
# Headphones 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 Headphones. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
import shutil
|
|
import uuid
|
|
import threading
|
|
import itertools
|
|
|
|
import os
|
|
import re
|
|
import beets
|
|
import headphones
|
|
from beets import autotag
|
|
from beets import config as beetsconfig
|
|
from beets import logging as beetslogging
|
|
from mediafile import MediaFile, FileTypeError, UnreadableFileError
|
|
from beetsplug import lyrics as beetslyrics
|
|
from headphones import notifiers, utorrent, transmission, deluge, qbittorrent, soulseek
|
|
from headphones import db, albumart, librarysync
|
|
from headphones import logger, helpers, mb, music_encoder
|
|
from headphones import metadata
|
|
|
|
postprocessor_lock = threading.Lock()
|
|
|
|
|
|
def checkFolder():
|
|
logger.info("Checking download folder for completed downloads (only snatched ones).")
|
|
|
|
with postprocessor_lock:
|
|
myDB = db.DBConnection()
|
|
snatched = myDB.select('SELECT * from snatched WHERE Status="Snatched"')
|
|
for album in snatched:
|
|
if album['FolderName']:
|
|
folder_name = album['FolderName']
|
|
single = False
|
|
|
|
# Soulseek, check download complete or errored
|
|
if album['Kind'] == 'soulseek':
|
|
match = re.search(r'\{(.*?)\}(.*?)$', folder_name) # get soulseek user from folder_name
|
|
user_name = match.group(1)
|
|
folder_name = match.group(2)
|
|
completed, errored = soulseek.download_completed_album(user_name, folder_name)
|
|
if errored:
|
|
# If the album had any tracks with errors in it, the whole download is considered faulty. Status will be reset to wanted.
|
|
logger.info(f"Soulseek: Album with folder '{folder_name}' had errors during download. Setting status to 'Wanted'.")
|
|
myDB.action('UPDATE albums SET Status="Wanted" WHERE AlbumID=? AND Status="Snatched"', (album['AlbumID'],))
|
|
myDB.action('UPDATE snatched SET status = "Unprocessed" WHERE AlbumID=?', (album['AlbumID'],))
|
|
|
|
# Folder will be removed from configured complete and Incomplete directory
|
|
complete_path = os.path.join(headphones.CONFIG.SOULSEEK_DOWNLOAD_DIR, folder_name)
|
|
incomplete_path = os.path.join(headphones.CONFIG.SOULSEEK_INCOMPLETE_DOWNLOAD_DIR, folder_name)
|
|
for path in [complete_path, incomplete_path]:
|
|
try:
|
|
shutil.rmtree(path)
|
|
except Exception as e:
|
|
pass
|
|
continue
|
|
elif completed:
|
|
download_dir = headphones.CONFIG.SOULSEEK_DOWNLOAD_DIR
|
|
else:
|
|
continue
|
|
elif album['Kind'] == 'nzb':
|
|
download_dir = headphones.CONFIG.DOWNLOAD_DIR
|
|
elif album['Kind'] == 'bandcamp':
|
|
download_dir = headphones.CONFIG.BANDCAMP_DIR
|
|
else:
|
|
if headphones.CONFIG.DELUGE_DONE_DIRECTORY and headphones.CONFIG.TORRENT_DOWNLOADER == 3:
|
|
download_dir = headphones.CONFIG.DELUGE_DONE_DIRECTORY
|
|
else:
|
|
download_dir = headphones.CONFIG.DOWNLOAD_TORRENT_DIR
|
|
|
|
# Get folder from torrent hash
|
|
if album['TorrentHash'] and headphones.CONFIG.TORRENT_DOWNLOADER:
|
|
torrent_folder_name = None
|
|
if headphones.CONFIG.TORRENT_DOWNLOADER == 1:
|
|
torrent_folder_name, single = transmission.getFolder(album['TorrentHash'])
|
|
elif headphones.CONFIG.TORRENT_DOWNLOADER == 4:
|
|
torrent_folder_name, single = qbittorrent.getFolder(album['TorrentHash'])
|
|
if torrent_folder_name:
|
|
folder_name = torrent_folder_name
|
|
|
|
if folder_name:
|
|
album_path = os.path.join(download_dir, folder_name)
|
|
logger.debug("Checking if %s exists" % album_path)
|
|
|
|
if os.path.exists(album_path):
|
|
logger.info('Found "' + folder_name + '" in ' + album[
|
|
'Kind'] + ' download folder. Verifying....')
|
|
verify(album['AlbumID'], album_path, album['Kind'], single=single)
|
|
else:
|
|
logger.info("No folder name found for " + album['Title'])
|
|
|
|
logger.debug("Checking download folder finished.")
|
|
|
|
|
|
def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=False, single=False):
|
|
myDB = db.DBConnection()
|
|
release = myDB.action('SELECT * from albums WHERE AlbumID=?', [albumid]).fetchone()
|
|
tracks = myDB.select('SELECT * from tracks WHERE AlbumID=?', [albumid])
|
|
|
|
if not release or not tracks:
|
|
release_list = None
|
|
|
|
# Fetch album information from MusicBrainz
|
|
try:
|
|
release_list = mb.getReleaseGroup(albumid)
|
|
except Exception as e:
|
|
logger.error(
|
|
'Unable to get release information for manual album with rgid: %s. Error: %s',
|
|
albumid, e)
|
|
return
|
|
|
|
if not release_list:
|
|
logger.error('Unable to get release information for manual album with rgid: %s',
|
|
albumid)
|
|
return
|
|
|
|
# Since we're just using this to create the bare minimum information to
|
|
# insert an artist/album combo, use the first release
|
|
releaseid = release_list[0]['id']
|
|
release_dict = mb.getRelease(releaseid)
|
|
|
|
if not release_dict:
|
|
logger.error(
|
|
'Unable to get release information for manual album with rgid: %s. Cannot continue',
|
|
albumid)
|
|
return
|
|
|
|
# Check if the artist is added to the database. In case the database is
|
|
# frozen during post processing, new artists will not be processed. This
|
|
# prevents new artists from appearing suddenly. In case forced is True,
|
|
# this check is skipped, since it is assumed the user wants this.
|
|
if headphones.CONFIG.FREEZE_DB and not forced:
|
|
artist = myDB.select(
|
|
"SELECT ArtistName, ArtistID FROM artists WHERE ArtistId=? OR ArtistName=?",
|
|
[release_dict['artist_id'], release_dict['artist_name']])
|
|
|
|
if not artist:
|
|
logger.warn("Continuing would add new artist '%s' (ID %s), "
|
|
"but database is frozen. Will skip postprocessing for "
|
|
"album with rgid: %s", release_dict['artist_name'],
|
|
release_dict['artist_id'], albumid)
|
|
|
|
myDB.action(
|
|
'UPDATE snatched SET status = "Frozen" WHERE status NOT LIKE "Seed%" and AlbumID=?',
|
|
[albumid])
|
|
frozen = re.search(r' \(Frozen\)(?:\[\d+\])?', albumpath)
|
|
if not frozen:
|
|
if headphones.CONFIG.RENAME_FROZEN:
|
|
renameUnprocessedFolder(albumpath, tag="Frozen")
|
|
else:
|
|
logger.warn("Won't rename %s to mark as 'Frozen', because it is disabled.",
|
|
albumpath)
|
|
return
|
|
|
|
logger.info("Now adding/updating artist: " + release_dict['artist_name'])
|
|
|
|
if release_dict['artist_name'].startswith('The '):
|
|
sortname = release_dict['artist_name'][4:]
|
|
else:
|
|
sortname = release_dict['artist_name']
|
|
|
|
controlValueDict = {"ArtistID": release_dict['artist_id']}
|
|
newValueDict = {"ArtistName": release_dict['artist_name'],
|
|
"ArtistSortName": sortname,
|
|
"DateAdded": helpers.today(),
|
|
"Status": "Paused"}
|
|
|
|
logger.info("ArtistID: " + release_dict['artist_id'] + " , ArtistName: " + release_dict[
|
|
'artist_name'])
|
|
|
|
if headphones.CONFIG.INCLUDE_EXTRAS:
|
|
newValueDict['IncludeExtras'] = 1
|
|
newValueDict['Extras'] = headphones.CONFIG.EXTRAS
|
|
|
|
myDB.upsert("artists", newValueDict, controlValueDict)
|
|
|
|
logger.info("Now adding album: " + release_dict['title'])
|
|
controlValueDict = {"AlbumID": albumid}
|
|
|
|
newValueDict = {"ArtistID": release_dict['artist_id'],
|
|
"ReleaseID": albumid,
|
|
"ArtistName": release_dict['artist_name'],
|
|
"AlbumTitle": release_dict['title'],
|
|
"AlbumASIN": release_dict['asin'],
|
|
"ReleaseDate": release_dict['date'],
|
|
"DateAdded": helpers.today(),
|
|
"Type": release_dict['rg_type'],
|
|
"Status": "Snatched"
|
|
}
|
|
|
|
myDB.upsert("albums", newValueDict, controlValueDict)
|
|
|
|
# Delete existing tracks associated with this AlbumID since we're going to replace them and don't want any extras
|
|
myDB.action('DELETE from tracks WHERE AlbumID=?', [albumid])
|
|
for track in release_dict['tracks']:
|
|
controlValueDict = {"TrackID": track['id'],
|
|
"AlbumID": albumid}
|
|
|
|
clean_name = helpers.clean_name(
|
|
release_dict['artist_name'] + ' ' + release_dict['title'] + ' ' + track['title'])
|
|
|
|
newValueDict = {"ArtistID": release_dict['artist_id'],
|
|
"ArtistName": release_dict['artist_name'],
|
|
"AlbumTitle": release_dict['title'],
|
|
"AlbumASIN": release_dict['asin'],
|
|
"TrackTitle": track['title'],
|
|
"TrackDuration": track['duration'],
|
|
"TrackNumber": track['number'],
|
|
"CleanName": clean_name
|
|
}
|
|
|
|
myDB.upsert("tracks", newValueDict, controlValueDict)
|
|
|
|
controlValueDict = {"ArtistID": release_dict['artist_id']}
|
|
newValueDict = {"Status": "Paused"}
|
|
|
|
myDB.upsert("artists", newValueDict, controlValueDict)
|
|
logger.info("Addition complete for: " + release_dict['title'] + " - " + release_dict[
|
|
'artist_name'])
|
|
|
|
release = myDB.action('SELECT * from albums WHERE AlbumID=?', [albumid]).fetchone()
|
|
tracks = myDB.select('SELECT * from tracks WHERE AlbumID=?', [albumid])
|
|
|
|
downloaded_track_list = []
|
|
downloaded_cuecount = 0
|
|
|
|
media_extensions = tuple(map(lambda x: '.' + x, headphones.MEDIA_FORMATS))
|
|
|
|
for root, dirs, files in os.walk(albumpath):
|
|
for file in files:
|
|
if file.endswith(media_extensions):
|
|
downloaded_track_list.append(os.path.join(root, file))
|
|
elif file.endswith('.cue'):
|
|
downloaded_cuecount += 1
|
|
# if any of the files end in *.part, we know the torrent isn't done yet. Process if forced, though
|
|
elif file.endswith(('.part', '.utpart')) and not forced:
|
|
logger.info(
|
|
"Looks like " + os.path.basename(albumpath) + " isn't complete yet. Will try again on the next run")
|
|
return
|
|
|
|
# Force single file through
|
|
if single and not downloaded_track_list:
|
|
downloaded_track_list.append(albumpath)
|
|
|
|
# Check to see if we're preserving the torrent dir
|
|
if (headphones.CONFIG.KEEP_TORRENT_FILES and Kind == "torrent") or headphones.CONFIG.KEEP_ORIGINAL_FOLDER:
|
|
keep_original_folder = True
|
|
|
|
# Split cue before metadata check
|
|
if headphones.CONFIG.CUE_SPLIT and downloaded_cuecount and downloaded_cuecount >= len(
|
|
downloaded_track_list):
|
|
new_folder = None
|
|
new_albumpath = albumpath
|
|
if keep_original_folder:
|
|
temp_path = helpers.preserve_torrent_directory(new_albumpath, forced)
|
|
if not temp_path:
|
|
markAsUnprocessed(albumid, new_albumpath, keep_original_folder)
|
|
return
|
|
else:
|
|
new_albumpath = temp_path
|
|
new_folder = os.path.split(new_albumpath)[0]
|
|
Kind = "cue_split"
|
|
cuepath = helpers.cue_split(new_albumpath)
|
|
if not cuepath:
|
|
if new_folder:
|
|
shutil.rmtree(new_folder)
|
|
markAsUnprocessed(albumid, albumpath, keep_original_folder)
|
|
return
|
|
else:
|
|
albumpath = cuepath
|
|
downloaded_track_list = helpers.get_downloaded_track_list(albumpath)
|
|
keep_original_folder = False
|
|
|
|
# test #1: metadata - usually works
|
|
logger.debug('Verifying metadata...')
|
|
|
|
for downloaded_track in downloaded_track_list:
|
|
try:
|
|
f = MediaFile(downloaded_track)
|
|
except Exception as e:
|
|
logger.info(f"Exception from MediaFile for {downloaded_track}: {e}")
|
|
continue
|
|
|
|
if not f.artist:
|
|
continue
|
|
if not f.album:
|
|
continue
|
|
|
|
metaartist = helpers.latinToAscii(f.artist.lower())
|
|
dbartist = helpers.latinToAscii(release['ArtistName'].lower())
|
|
metaalbum = helpers.latinToAscii(f.album.lower())
|
|
dbalbum = helpers.latinToAscii(release['AlbumTitle'].lower())
|
|
|
|
logger.debug('Matching metadata artist: %s with artist name: %s' % (metaartist, dbartist))
|
|
logger.debug('Matching metadata album: %s with album name: %s' % (metaalbum, dbalbum))
|
|
|
|
if metaartist == dbartist and metaalbum == dbalbum:
|
|
doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, Kind,
|
|
keep_original_folder, forced, single)
|
|
return
|
|
|
|
# test #2: filenames
|
|
logger.debug('Metadata check failed. Verifying filenames...')
|
|
for downloaded_track in downloaded_track_list:
|
|
track_name = os.path.splitext(downloaded_track)[0]
|
|
split_track_name = re.sub(r'[\.\-\_]', r' ', track_name).lower()
|
|
for track in tracks:
|
|
|
|
if not track['TrackTitle']:
|
|
continue
|
|
|
|
dbtrack = helpers.latinToAscii(track['TrackTitle'].lower())
|
|
filetrack = helpers.latinToAscii(split_track_name)
|
|
logger.debug('Checking if track title: %s is in file name: %s' % (dbtrack, filetrack))
|
|
|
|
if dbtrack in filetrack:
|
|
doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, Kind,
|
|
keep_original_folder, forced, single)
|
|
return
|
|
|
|
# test #3: number of songs and duration
|
|
logger.debug('Filename check failed. Verifying album length...')
|
|
db_track_duration = 0
|
|
downloaded_track_duration = 0
|
|
|
|
logger.debug('Total music files in %s: %i' % (albumpath, len(downloaded_track_list)))
|
|
logger.debug('Total tracks for this album in the database: %i' % len(tracks))
|
|
if len(tracks) == len(downloaded_track_list):
|
|
|
|
for track in tracks:
|
|
try:
|
|
db_track_duration += track['TrackDuration'] / 1000
|
|
except:
|
|
downloaded_track_duration = False
|
|
break
|
|
|
|
for downloaded_track in downloaded_track_list:
|
|
try:
|
|
f = MediaFile(downloaded_track)
|
|
downloaded_track_duration += f.length
|
|
except:
|
|
downloaded_track_duration = False
|
|
break
|
|
|
|
if downloaded_track_duration and db_track_duration:
|
|
logger.debug('Downloaded album duration: %i' % downloaded_track_duration)
|
|
logger.debug('Database track duration: %i' % db_track_duration)
|
|
delta = abs(downloaded_track_duration - db_track_duration)
|
|
if delta < 240:
|
|
doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, Kind,
|
|
keep_original_folder, forced, single)
|
|
return
|
|
|
|
logger.warn(f"Could not identify {albumpath}. It may not be the intended album")
|
|
markAsUnprocessed(albumid, albumpath, keep_original_folder)
|
|
|
|
def markAsUnprocessed(albumid, albumpath, keep_original_folder=False):
|
|
myDB = db.DBConnection()
|
|
myDB.action(
|
|
'UPDATE snatched SET status = "Unprocessed" WHERE status NOT LIKE "Seed%" and AlbumID=?', [albumid])
|
|
processed = re.search(r' \(Unprocessed\)(?:\[\d+\])?', albumpath)
|
|
if not processed:
|
|
if headphones.CONFIG.RENAME_UNPROCESSED and not keep_original_folder:
|
|
renameUnprocessedFolder(albumpath, tag="Unprocessed")
|
|
else:
|
|
logger.warn(
|
|
f"Won't rename {albumpath} to mark as 'Unprocessed', "
|
|
f"because it is disabled or folder is being kept."
|
|
)
|
|
return
|
|
|
|
|
|
def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, Kind=None,
|
|
keep_original_folder=False, forced=False, single=False):
|
|
logger.info(
|
|
f"Starting post-processing for: {release['ArtistName']} - "
|
|
f"{release['AlbumTitle']}"
|
|
)
|
|
new_folder = None
|
|
|
|
# Preserve the torrent dir
|
|
if keep_original_folder or single:
|
|
temp_path = helpers.preserve_torrent_directory(albumpath, forced, single)
|
|
if not temp_path:
|
|
markAsUnprocessed(albumid, albumpath, keep_original_folder)
|
|
return
|
|
else:
|
|
albumpath = temp_path
|
|
new_folder = os.path.split(albumpath)[0]
|
|
elif Kind == "cue_split":
|
|
new_folder = os.path.split(albumpath)[0]
|
|
|
|
# Need to update the downloaded track list with the new location.
|
|
# Could probably just throw in the "headphones-modified" folder,
|
|
# but this is good to make sure we're not counting files that may have failed to move
|
|
if new_folder:
|
|
downloaded_track_list = []
|
|
for r, d, f in os.walk(albumpath):
|
|
for files in f:
|
|
if any(files.lower().endswith('.' + x.lower()) for x in headphones.MEDIA_FORMATS):
|
|
downloaded_track_list.append(os.path.join(r, files))
|
|
|
|
builder = metadata.AlbumMetadataBuilder()
|
|
# Check if files are valid media files and are writable, before the steps
|
|
# below are executed. This simplifies errors and prevents unfinished steps.
|
|
for downloaded_track in downloaded_track_list:
|
|
try:
|
|
f = MediaFile(downloaded_track)
|
|
builder.add_media_file(f)
|
|
except (FileTypeError, UnreadableFileError):
|
|
logger.error(f"`{downloaded_track}` is not a valid media file. Not continuing.")
|
|
return
|
|
except IOError:
|
|
logger.error(f"Unable to find `{downloaded_track}`. Not continuing.")
|
|
if new_folder:
|
|
shutil.rmtree(new_folder)
|
|
return
|
|
|
|
# If one of the options below is set, it will access/touch/modify the
|
|
# files, which requires write permissions. This step just check this, so
|
|
# it will not try and fail lateron, with strange exceptions.
|
|
if headphones.CONFIG.EMBED_ALBUM_ART or headphones.CONFIG.CLEANUP_FILES or \
|
|
headphones.CONFIG.ADD_ALBUM_ART or headphones.CONFIG.CORRECT_METADATA or \
|
|
headphones.CONFIG.EMBED_LYRICS or headphones.CONFIG.RENAME_FILES or \
|
|
headphones.CONFIG.MOVE_FILES:
|
|
|
|
try:
|
|
with open(downloaded_track, "a+b") as fp:
|
|
fp.seek(0)
|
|
except IOError as e:
|
|
logger.debug("Write check exact error: %s", e)
|
|
logger.error(
|
|
f"`{downloaded_track}` is not writable. This is required "
|
|
"for some post processing steps. Not continuing."
|
|
)
|
|
if new_folder:
|
|
shutil.rmtree(new_folder)
|
|
return
|
|
|
|
metadata_dict = builder.build()
|
|
# start encoding
|
|
if headphones.CONFIG.MUSIC_ENCODER:
|
|
downloaded_track_list = music_encoder.encode(albumpath)
|
|
|
|
if not downloaded_track_list:
|
|
if new_folder:
|
|
shutil.rmtree(new_folder)
|
|
return
|
|
|
|
# get artwork and path
|
|
album_art_path = None
|
|
artwork = None
|
|
if headphones.CONFIG.EMBED_ALBUM_ART or headphones.CONFIG.ADD_ALBUM_ART or \
|
|
(headphones.CONFIG.PLEX_ENABLED and headphones.CONFIG.PLEX_NOTIFY) or \
|
|
(headphones.CONFIG.XBMC_ENABLED and headphones.CONFIG.XBMC_NOTIFY):
|
|
album_art_path, artwork = albumart.getAlbumArt(albumid)
|
|
|
|
if headphones.CONFIG.EMBED_ALBUM_ART and artwork:
|
|
embedAlbumArt(artwork, downloaded_track_list)
|
|
|
|
if headphones.CONFIG.CLEANUP_FILES:
|
|
cleanupFiles(albumpath)
|
|
|
|
if headphones.CONFIG.KEEP_NFO:
|
|
renameNFO(albumpath)
|
|
|
|
if headphones.CONFIG.ADD_ALBUM_ART and artwork:
|
|
addAlbumArt(artwork, albumpath, release, metadata_dict)
|
|
|
|
if headphones.CONFIG.CORRECT_METADATA:
|
|
correctedMetadata = correctMetadata(albumid, release, downloaded_track_list)
|
|
if not correctedMetadata and headphones.CONFIG.DO_NOT_PROCESS_UNMATCHED:
|
|
if new_folder:
|
|
shutil.rmtree(new_folder)
|
|
return
|
|
|
|
if headphones.CONFIG.EMBED_LYRICS:
|
|
embedLyrics(downloaded_track_list)
|
|
|
|
if headphones.CONFIG.RENAME_FILES:
|
|
renameFiles(albumpath, downloaded_track_list, release)
|
|
|
|
if headphones.CONFIG.MOVE_FILES and not headphones.CONFIG.DESTINATION_DIR:
|
|
logger.error(
|
|
'No DESTINATION_DIR has been set. Set "Destination Directory" to the parent directory you want to move the files to')
|
|
albumpaths = [albumpath]
|
|
elif headphones.CONFIG.MOVE_FILES and headphones.CONFIG.DESTINATION_DIR:
|
|
albumpaths = moveFiles(albumpath, release, metadata_dict)
|
|
else:
|
|
albumpaths = [albumpath]
|
|
|
|
if headphones.CONFIG.FILE_PERMISSIONS_ENABLED:
|
|
updateFilePermissions(albumpaths)
|
|
|
|
myDB = db.DBConnection()
|
|
myDB.action('UPDATE albums SET status = "Downloaded" WHERE AlbumID=?', [albumid])
|
|
myDB.action(
|
|
'UPDATE snatched SET status = "Processed" WHERE Status NOT LIKE "Seed%" and AlbumID=?',
|
|
[albumid])
|
|
|
|
# Check if torrent has finished seeding
|
|
if headphones.CONFIG.TORRENT_DOWNLOADER != 0:
|
|
seed_snatched = myDB.action(
|
|
'SELECT * from snatched WHERE Status="Seed_Snatched" and AlbumID=?',
|
|
[albumid]).fetchone()
|
|
if seed_snatched:
|
|
hash = seed_snatched['TorrentHash']
|
|
torrent_removed = False
|
|
logger.info('%s - %s. Checking if torrent has finished seeding and can be removed' % (
|
|
release['ArtistName'], release['AlbumTitle']))
|
|
if headphones.CONFIG.TORRENT_DOWNLOADER == 1:
|
|
torrent_removed = transmission.removeTorrent(hash, True)
|
|
elif headphones.CONFIG.TORRENT_DOWNLOADER == 3: # Deluge
|
|
torrent_removed = deluge.removeTorrent(hash, True)
|
|
elif headphones.CONFIG.TORRENT_DOWNLOADER == 2:
|
|
torrent_removed = utorrent.removeTorrent(hash, True)
|
|
else:
|
|
torrent_removed = qbittorrent.removeTorrent(hash, True)
|
|
|
|
# Torrent removed, delete the snatched record, else update Status for scheduled job to check
|
|
if torrent_removed:
|
|
myDB.action('DELETE from snatched WHERE status = "Seed_Snatched" and AlbumID=?',
|
|
[albumid])
|
|
else:
|
|
myDB.action(
|
|
'UPDATE snatched SET status = "Seed_Processed" WHERE status = "Seed_Snatched" and AlbumID=?',
|
|
[albumid])
|
|
|
|
# Update the have tracks for all created dirs:
|
|
for albumpath in albumpaths:
|
|
librarysync.libraryScan(dir=albumpath, append=True, ArtistID=release['ArtistID'],
|
|
ArtistName=release['ArtistName'])
|
|
|
|
logger.info(
|
|
'Post-processing for %s - %s complete' % (release['ArtistName'], release['AlbumTitle']))
|
|
|
|
pushmessage = release['ArtistName'] + ' - ' + release['AlbumTitle']
|
|
statusmessage = "Download and Postprocessing completed"
|
|
|
|
if headphones.CONFIG.GROWL_ENABLED:
|
|
logger.info("Growl request")
|
|
growl = notifiers.GROWL()
|
|
growl.notify(pushmessage, statusmessage)
|
|
|
|
if headphones.CONFIG.PROWL_ENABLED:
|
|
logger.info("Prowl request")
|
|
prowl = notifiers.PROWL()
|
|
prowl.notify(pushmessage, statusmessage)
|
|
|
|
if headphones.CONFIG.XBMC_ENABLED:
|
|
xbmc = notifiers.XBMC()
|
|
if headphones.CONFIG.XBMC_UPDATE:
|
|
xbmc.update()
|
|
if headphones.CONFIG.XBMC_NOTIFY:
|
|
xbmc.notify(release['ArtistName'],
|
|
release['AlbumTitle'],
|
|
album_art_path)
|
|
|
|
if headphones.CONFIG.LMS_ENABLED:
|
|
lms = notifiers.LMS()
|
|
lms.update()
|
|
|
|
if headphones.CONFIG.PLEX_ENABLED:
|
|
plex = notifiers.Plex()
|
|
if headphones.CONFIG.PLEX_UPDATE:
|
|
plex.update()
|
|
if headphones.CONFIG.PLEX_NOTIFY:
|
|
plex.notify(release['ArtistName'],
|
|
release['AlbumTitle'],
|
|
album_art_path)
|
|
|
|
if headphones.CONFIG.NMA_ENABLED:
|
|
nma = notifiers.NMA()
|
|
nma.notify(release['ArtistName'], release['AlbumTitle'])
|
|
|
|
if headphones.CONFIG.PUSHALOT_ENABLED:
|
|
logger.info("Pushalot request")
|
|
pushalot = notifiers.PUSHALOT()
|
|
pushalot.notify(pushmessage, statusmessage)
|
|
|
|
if headphones.CONFIG.SYNOINDEX_ENABLED:
|
|
syno = notifiers.Synoindex()
|
|
for albumpath in albumpaths:
|
|
syno.notify(albumpath)
|
|
|
|
if headphones.CONFIG.PUSHOVER_ENABLED:
|
|
logger.info("Pushover request")
|
|
pushover = notifiers.PUSHOVER()
|
|
pushover.notify(pushmessage, "Headphones")
|
|
|
|
if headphones.CONFIG.PUSHBULLET_ENABLED:
|
|
logger.info("PushBullet request")
|
|
pushbullet = notifiers.PUSHBULLET()
|
|
pushbullet.notify(pushmessage, statusmessage)
|
|
|
|
if headphones.CONFIG.JOIN_ENABLED:
|
|
logger.info("Join request")
|
|
join = notifiers.JOIN()
|
|
join.notify(pushmessage, statusmessage)
|
|
|
|
if headphones.CONFIG.TELEGRAM_ENABLED:
|
|
logger.info("Telegram request")
|
|
telegram = notifiers.TELEGRAM()
|
|
telegram.notify(statusmessage, pushmessage)
|
|
|
|
if headphones.CONFIG.TWITTER_ENABLED:
|
|
logger.info("Twitter notifications temporarily disabled")
|
|
#logger.info("Sending Twitter notification")
|
|
#twitter = notifiers.TwitterNotifier()
|
|
#twitter.notify_download(pushmessage)
|
|
|
|
if headphones.CONFIG.OSX_NOTIFY_ENABLED:
|
|
from headphones import cache
|
|
c = cache.Cache()
|
|
album_art = c.get_artwork_from_cache(None, release['AlbumID'])
|
|
logger.info("Sending OS X notification")
|
|
osx_notify = notifiers.OSX_NOTIFY()
|
|
osx_notify.notify(release['ArtistName'],
|
|
release['AlbumTitle'],
|
|
statusmessage,
|
|
image=album_art)
|
|
|
|
if headphones.CONFIG.BOXCAR_ENABLED:
|
|
logger.info("Sending Boxcar2 notification")
|
|
boxcar = notifiers.BOXCAR()
|
|
boxcar.notify('Headphones processed: ' + pushmessage,
|
|
statusmessage, release['AlbumID'])
|
|
|
|
if headphones.CONFIG.SUBSONIC_ENABLED:
|
|
logger.info("Sending Subsonic update")
|
|
subsonic = notifiers.SubSonicNotifier()
|
|
subsonic.notify(albumpaths)
|
|
|
|
if headphones.CONFIG.MPC_ENABLED:
|
|
mpc = notifiers.MPC()
|
|
mpc.notify()
|
|
|
|
if headphones.CONFIG.EMAIL_ENABLED:
|
|
logger.info("Sending Email notification")
|
|
email = notifiers.Email()
|
|
subject = release['ArtistName'] + ' - ' + release['AlbumTitle']
|
|
email.notify(subject, "Download and Postprocessing completed")
|
|
|
|
if new_folder:
|
|
shutil.rmtree(new_folder)
|
|
|
|
|
|
def embedAlbumArt(artwork, downloaded_track_list):
|
|
logger.info('Embedding album art')
|
|
|
|
for downloaded_track in downloaded_track_list:
|
|
try:
|
|
f = MediaFile(downloaded_track)
|
|
except:
|
|
logger.error(f"Could not read {downloaded_track}. Not adding album art")
|
|
continue
|
|
|
|
logger.debug(f"Adding album art to `{downloaded_track}`")
|
|
|
|
try:
|
|
f.art = artwork
|
|
f.save()
|
|
except Exception as e:
|
|
logger.error(f"Error embedding album art to `{downloaded_track}`: {e}")
|
|
continue
|
|
|
|
|
|
def addAlbumArt(artwork, albumpath, release, metadata_dict):
|
|
logger.info(f"Adding album art to `{albumpath}`")
|
|
md = metadata.album_metadata(albumpath, release, metadata_dict)
|
|
|
|
ext = ".jpg"
|
|
# PNGs are possibe here too
|
|
if artwork[:4] == '\x89PNG':
|
|
ext = ".png"
|
|
|
|
album_art_name = helpers.pattern_substitute(
|
|
headphones.CONFIG.ALBUM_ART_FORMAT.strip(), md) + ext
|
|
|
|
album_art_name = helpers.replace_illegal_chars(album_art_name)
|
|
|
|
if headphones.CONFIG.FILE_UNDERSCORES:
|
|
album_art_name = album_art_name.replace(' ', '_')
|
|
|
|
if album_art_name.startswith('.'):
|
|
album_art_name = album_art_name.replace(".", "_", 1)
|
|
|
|
try:
|
|
with open(os.path.join(albumpath, album_art_name), 'wb') as f:
|
|
f.write(artwork)
|
|
except IOError as e:
|
|
logger.error('Error saving album art: %s', e)
|
|
return
|
|
|
|
|
|
def cleanupFiles(albumpath):
|
|
logger.info('Cleaning up files')
|
|
|
|
for r, d, f in os.walk(albumpath):
|
|
for file in f:
|
|
if not any(file.lower().endswith('.' + x.lower()) for x in headphones.MEDIA_FORMATS):
|
|
logger.debug('Removing: %s' % file)
|
|
try:
|
|
os.remove(os.path.join(r, file))
|
|
except Exception as e:
|
|
logger.error('Could not remove file: %s. Error: %s' % (file, e))
|
|
|
|
|
|
def renameNFO(albumpath):
|
|
logger.info('Renaming NFO')
|
|
|
|
for r, d, f in os.walk(albumpath):
|
|
for file in f:
|
|
if file.lower().endswith('.nfo'):
|
|
if not file.lower().endswith('.orig.nfo'):
|
|
try:
|
|
new_file_name = os.path.join(r, file)[:-3] + 'orig.nfo'
|
|
logger.debug(f"Renaming `{file}` to `{new_file_name}`")
|
|
os.rename(os.path.join(r, file), new_file_name)
|
|
except Exception as e:
|
|
logger.error(f"Could not rename {file}: {e}")
|
|
|
|
|
|
def moveFiles(albumpath, release, metadata_dict):
|
|
logger.info(f"Moving files: `{albumpath}`")
|
|
|
|
md = metadata.album_metadata(albumpath, release, metadata_dict)
|
|
folder = helpers.pattern_substitute(
|
|
headphones.CONFIG.FOLDER_FORMAT.strip(), md, normalize=True)
|
|
|
|
if headphones.CONFIG.FILE_UNDERSCORES:
|
|
folder = folder.replace(' ', '_')
|
|
|
|
folder = helpers.replace_illegal_chars(folder, type="folder")
|
|
folder = folder.replace('./', '_/').replace('/.', '/_')
|
|
|
|
if folder.endswith('.'):
|
|
folder = folder[:-1] + '_'
|
|
|
|
if folder.startswith('.'):
|
|
folder = '_' + folder[1:]
|
|
|
|
# Grab our list of files early on so we can determine if we need to create
|
|
# the lossy_dest_dir, lossless_dest_dir, or both
|
|
files_to_move = []
|
|
lossy_media = False
|
|
lossless_media = False
|
|
|
|
for r, d, f in os.walk(albumpath):
|
|
for files in f:
|
|
files_to_move.append(os.path.join(r, files))
|
|
if any(files.lower().endswith('.' + x.lower()) for x in headphones.LOSSY_MEDIA_FORMATS):
|
|
lossy_media = True
|
|
if any(files.lower().endswith('.' + x.lower()) for x in
|
|
headphones.LOSSLESS_MEDIA_FORMATS):
|
|
lossless_media = True
|
|
|
|
# Do some sanity checking to see what directories we need to create:
|
|
make_lossy_folder = False
|
|
make_lossless_folder = False
|
|
|
|
lossy_destination_path = os.path.join(headphones.CONFIG.DESTINATION_DIR, folder)
|
|
lossless_destination_path = os.path.join(headphones.CONFIG.LOSSLESS_DESTINATION_DIR, folder)
|
|
|
|
# If they set a destination dir for lossless media, only create the lossy folder if there is lossy media
|
|
if headphones.CONFIG.LOSSLESS_DESTINATION_DIR:
|
|
if lossy_media:
|
|
make_lossy_folder = True
|
|
if lossless_media:
|
|
make_lossless_folder = True
|
|
# If they haven't set a lossless dest_dir, just create the "lossy" folder
|
|
else:
|
|
make_lossy_folder = True
|
|
|
|
last_folder = headphones.CONFIG.FOLDER_FORMAT.strip().split('/')[-1]
|
|
|
|
if make_lossless_folder:
|
|
# Only rename the folder if they use the album name, otherwise merge into existing folder
|
|
if os.path.exists(lossless_destination_path) and 'album' in last_folder.lower():
|
|
|
|
create_duplicate_folder = False
|
|
|
|
if headphones.CONFIG.REPLACE_EXISTING_FOLDERS:
|
|
try:
|
|
shutil.rmtree(lossless_destination_path)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error deleting `{lossless_destination_path}`. "
|
|
f"Creating duplicate folder. Error: {e}"
|
|
)
|
|
create_duplicate_folder = True
|
|
|
|
if not headphones.CONFIG.REPLACE_EXISTING_FOLDERS or create_duplicate_folder:
|
|
temp_folder = folder
|
|
|
|
i = 1
|
|
while True:
|
|
newfolder = temp_folder + '[%i]' % i
|
|
lossless_destination_path = os.path.normpath(
|
|
os.path.join(
|
|
headphones.CONFIG.LOSSLESS_DESTINATION_DIR,
|
|
newfolder
|
|
)
|
|
)
|
|
if os.path.exists(lossless_destination_path):
|
|
i += 1
|
|
else:
|
|
temp_folder = newfolder
|
|
break
|
|
|
|
if not os.path.exists(lossless_destination_path):
|
|
try:
|
|
os.makedirs(lossless_destination_path)
|
|
except Exception as e:
|
|
logger.error('Could not create lossless folder for %s. (Error: %s)' % (
|
|
release['AlbumTitle'], e))
|
|
if not make_lossy_folder:
|
|
return [albumpath]
|
|
|
|
if make_lossy_folder:
|
|
if os.path.exists(lossy_destination_path) and 'album' in last_folder.lower():
|
|
|
|
create_duplicate_folder = False
|
|
|
|
if headphones.CONFIG.REPLACE_EXISTING_FOLDERS:
|
|
try:
|
|
shutil.rmtree(lossy_destination_path)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error deleting `{lossy_destination_path}`. "
|
|
f"Creating duplicate folder. Error: {e}"
|
|
)
|
|
create_duplicate_folder = True
|
|
|
|
if not headphones.CONFIG.REPLACE_EXISTING_FOLDERS or create_duplicate_folder:
|
|
temp_folder = folder
|
|
|
|
i = 1
|
|
while True:
|
|
newfolder = temp_folder + '[%i]' % i
|
|
lossy_destination_path = os.path.normpath(
|
|
os.path.join(
|
|
headphones.CONFIG.DESTINATION_DIR,
|
|
newfolder
|
|
)
|
|
)
|
|
if os.path.exists(lossy_destination_path):
|
|
i += 1
|
|
else:
|
|
temp_folder = newfolder
|
|
break
|
|
|
|
if not os.path.exists(lossy_destination_path):
|
|
try:
|
|
os.makedirs(lossy_destination_path)
|
|
except Exception as e:
|
|
logger.error(
|
|
'Could not create folder for %s. Not moving: %s' % (release['AlbumTitle'], e))
|
|
return [albumpath]
|
|
|
|
logger.info('Checking which files we need to move.....')
|
|
|
|
# Move files to the destination folder, renaming them if they already exist
|
|
# If we have two desination_dirs, move non-music files to both
|
|
if make_lossy_folder and make_lossless_folder:
|
|
|
|
for file_to_move in files_to_move:
|
|
|
|
if any(file_to_move.lower().endswith('.' + x.lower()) for x in
|
|
headphones.LOSSY_MEDIA_FORMATS):
|
|
helpers.smartMove(file_to_move, lossy_destination_path)
|
|
|
|
elif any(file_to_move.lower().endswith('.' + x.lower()) for x in
|
|
headphones.LOSSLESS_MEDIA_FORMATS):
|
|
helpers.smartMove(file_to_move, lossless_destination_path)
|
|
|
|
# If it's a non-music file, move it to both dirs
|
|
# TODO: Move specific-to-lossless files to the lossless dir only
|
|
else:
|
|
|
|
moved_to_lossy_folder = helpers.smartMove(file_to_move, lossy_destination_path,
|
|
delete=False)
|
|
moved_to_lossless_folder = helpers.smartMove(file_to_move,
|
|
lossless_destination_path,
|
|
delete=False)
|
|
|
|
if moved_to_lossy_folder or moved_to_lossless_folder:
|
|
try:
|
|
os.remove(file_to_move)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error deleting `{file_to_move}` from source directory")
|
|
else:
|
|
logger.error(
|
|
f"Error copying `{file_to_move}`. "
|
|
f"Not deleting from download directory")
|
|
elif make_lossless_folder and not make_lossy_folder:
|
|
|
|
for file_to_move in files_to_move:
|
|
helpers.smartMove(file_to_move, lossless_destination_path)
|
|
|
|
else:
|
|
|
|
for file_to_move in files_to_move:
|
|
helpers.smartMove(file_to_move, lossy_destination_path)
|
|
|
|
# Chmod the directories using the folder_format (script courtesy of premiso!)
|
|
folder_list = folder.split('/')
|
|
temp_fs = []
|
|
|
|
if make_lossless_folder:
|
|
temp_fs.append(headphones.CONFIG.LOSSLESS_DESTINATION_DIR)
|
|
|
|
if make_lossy_folder:
|
|
temp_fs.append(headphones.CONFIG.DESTINATION_DIR)
|
|
|
|
for temp_f in temp_fs:
|
|
|
|
for f in folder_list:
|
|
|
|
temp_f = os.path.join(temp_f, f)
|
|
|
|
if headphones.CONFIG.FOLDER_PERMISSIONS_ENABLED:
|
|
try:
|
|
os.chmod(os.path.normpath(temp_f),
|
|
int(headphones.CONFIG.FOLDER_PERMISSIONS, 8))
|
|
except Exception as e:
|
|
logger.error(f"Error trying to change permissions on `{temp_f}`: {e}")
|
|
else:
|
|
logger.debug(
|
|
f"Not changing permissions on `{temp_f}`, "
|
|
"since it is disabled")
|
|
|
|
# If we failed to move all the files out of the directory, this will fail too
|
|
try:
|
|
shutil.rmtree(albumpath)
|
|
except Exception as e:
|
|
logger.error(f"Could not remove `{albumpath}`: {e}")
|
|
|
|
destination_paths = []
|
|
|
|
if make_lossy_folder:
|
|
destination_paths.append(lossy_destination_path)
|
|
if make_lossless_folder:
|
|
destination_paths.append(lossless_destination_path)
|
|
|
|
return destination_paths
|
|
|
|
|
|
def correctMetadata(albumid, release, downloaded_track_list):
|
|
logger.info('Preparing to write metadata to tracks....')
|
|
lossy_items = []
|
|
lossless_items = []
|
|
|
|
# Process lossless & lossy media formats separately
|
|
for downloaded_track in downloaded_track_list:
|
|
|
|
try:
|
|
|
|
if any(downloaded_track.lower().endswith('.' + x.lower()) for x in
|
|
headphones.LOSSLESS_MEDIA_FORMATS):
|
|
lossless_items.append(beets.library.Item.from_path(downloaded_track))
|
|
elif any(downloaded_track.lower().endswith('.' + x.lower()) for x in
|
|
headphones.LOSSY_MEDIA_FORMATS):
|
|
lossy_items.append(beets.library.Item.from_path(downloaded_track))
|
|
else:
|
|
logger.warn(
|
|
f"Skipping `{downloaded_track}` because it is "
|
|
f"not a mutagen friendly file format"
|
|
)
|
|
continue
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Beets couldn't create an Item from `{downloaded_track}`: {e}")
|
|
continue
|
|
|
|
for items in [lossy_items, lossless_items]:
|
|
|
|
if not items:
|
|
continue
|
|
|
|
search_ids = []
|
|
logger.debug('Getting recommendation from beets. Artist: %s. Album: %s. Tracks: %s', release['ArtistName'],
|
|
release['AlbumTitle'], len(items))
|
|
|
|
# Try with specific release, e.g. alternate release selected from albumPage
|
|
if release['ReleaseID'] != release['AlbumID']:
|
|
logger.debug('trying beets with specific Release ID: %s', release['ReleaseID'])
|
|
search_ids = [release['ReleaseID']]
|
|
|
|
try:
|
|
beetslog = beetslogging.getLogger('beets')
|
|
beetslog.set_global_level(beetslogging.DEBUG) if headphones.VERBOSE else beetslog.set_global_level(
|
|
beetslogging.CRITICAL)
|
|
with helpers.capture_beets_log() as logs:
|
|
cur_artist, cur_album, prop = autotag.tag_album(items,
|
|
search_artist=release['ArtistName'],
|
|
search_album=release['AlbumTitle'],
|
|
search_ids=search_ids)
|
|
candidates = prop.candidates
|
|
rec = prop.recommendation
|
|
for log in logs:
|
|
logger.debug('Beets: %s', log)
|
|
beetslog.set_global_level(beetslogging.NOTSET)
|
|
except Exception as e:
|
|
logger.error('Error getting recommendation: %s. Not writing metadata', e)
|
|
return False
|
|
if str(rec) == 'Recommendation.none':
|
|
logger.warn('No accurate album match found for %s, %s - not writing metadata',
|
|
release['ArtistName'], release['AlbumTitle'])
|
|
return False
|
|
|
|
if candidates:
|
|
dist, info, mapping, extra_items, extra_tracks = candidates[0]
|
|
else:
|
|
logger.warn('No accurate album match found for %s, %s - not writing metadata',
|
|
release['ArtistName'], release['AlbumTitle'])
|
|
return False
|
|
|
|
logger.info('Beets recommendation for tagging items: %s' % rec)
|
|
|
|
# TODO: Handle extra_items & extra_tracks
|
|
|
|
autotag.apply_metadata(info, mapping)
|
|
|
|
# Set ID3 tag version
|
|
if headphones.CONFIG.IDTAG:
|
|
beetsconfig['id3v23'] = True
|
|
logger.debug("Using ID3v2.3")
|
|
else:
|
|
beetsconfig['id3v23'] = False
|
|
logger.debug("Using ID3v2.4")
|
|
|
|
for item in items:
|
|
try:
|
|
item.write()
|
|
logger.info(f"Successfully applied metadata to `{item.path}`")
|
|
except Exception as e:
|
|
logger.warn(f"Error writing metadata to `{item.path}: {e}")
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def embedLyrics(downloaded_track_list):
|
|
logger.info('Adding lyrics')
|
|
|
|
# TODO: If adding lyrics for flac & lossy, only fetch the lyrics once and apply it to both files
|
|
# TODO: Get beets to add automatically by enabling the plugin
|
|
|
|
lossy_items = []
|
|
lossless_items = []
|
|
lp = beetslyrics.LyricsPlugin()
|
|
|
|
for downloaded_track in downloaded_track_list:
|
|
|
|
try:
|
|
if any(downloaded_track.lower().endswith('.' + x.lower()) for x in
|
|
headphones.LOSSLESS_MEDIA_FORMATS):
|
|
lossless_items.append(beets.library.Item.from_path(downloaded_track))
|
|
elif any(downloaded_track.lower().endswith('.' + x.lower()) for x in
|
|
headphones.LOSSY_MEDIA_FORMATS):
|
|
lossy_items.append(beets.library.Item.from_path(downloaded_track))
|
|
else:
|
|
logger.warn(
|
|
f"Skipping `{downloaded_track}` because it is "
|
|
f"not a mutagen friendly file format")
|
|
except Exception as e:
|
|
logger.error(f"Beets couldn't create an Item from `{downloaded_track}`: {e}")
|
|
|
|
for items in [lossy_items, lossless_items]:
|
|
|
|
if not items:
|
|
continue
|
|
|
|
for item in items:
|
|
|
|
lyrics = None
|
|
for artist, titles in beetslyrics.search_pairs(item):
|
|
lyrics = [lp.get_lyrics(artist, title) for title in titles]
|
|
if any(lyrics):
|
|
break
|
|
|
|
lyrics = "\n\n---\n\n".join([l for l in lyrics if l])
|
|
|
|
if lyrics:
|
|
logger.debug('Adding lyrics to: %s', item.title)
|
|
item.lyrics = lyrics
|
|
try:
|
|
item.write()
|
|
except Exception as e:
|
|
logger.error('Cannot save lyrics to: %s. Skipping', item.title)
|
|
else:
|
|
logger.debug('No lyrics found for track: %s', item.title)
|
|
|
|
|
|
def renameFiles(albumpath, downloaded_track_list, release):
|
|
logger.info('Renaming files')
|
|
# Until tagging works better I'm going to rely on the already provided metadata
|
|
|
|
for downloaded_track in downloaded_track_list:
|
|
md, from_metadata = metadata.file_metadata(
|
|
downloaded_track,
|
|
release,
|
|
headphones.CONFIG.RENAME_SINGLE_DISC_IGNORE
|
|
)
|
|
if md is None:
|
|
# unable to parse media file, skip file
|
|
continue
|
|
|
|
ext = md[metadata.Vars.EXTENSION]
|
|
if not from_metadata:
|
|
title = md[metadata.Vars.TITLE]
|
|
new_file_name = helpers.cleanTitle(title) + ext
|
|
else:
|
|
new_file_name = helpers.pattern_substitute(
|
|
headphones.CONFIG.FILE_FORMAT.strip(), md
|
|
).replace('/', '_') + ext
|
|
|
|
new_file_name = helpers.replace_illegal_chars(new_file_name)
|
|
|
|
if headphones.CONFIG.FILE_UNDERSCORES:
|
|
new_file_name = new_file_name.replace(' ', '_')
|
|
|
|
if new_file_name.startswith('.'):
|
|
new_file_name = new_file_name.replace(".", "_", 1)
|
|
|
|
new_file = os.path.join(albumpath, new_file_name)
|
|
|
|
if downloaded_track == new_file_name:
|
|
logger.debug(f"Renaming for {downloaded_track} is not neccessary")
|
|
continue
|
|
|
|
logger.debug(f"Renaming {downloaded_track} ---> {new_file_name}")
|
|
try:
|
|
os.rename(downloaded_track, new_file)
|
|
except Exception as e:
|
|
logger.error(f"Error renaming {downloaded_track}: {e}")
|
|
continue
|
|
|
|
|
|
def updateFilePermissions(albumpaths):
|
|
for folder in albumpaths:
|
|
logger.info(f"Updating file permissions in `{folder}`")
|
|
for r, d, f in os.walk(folder):
|
|
for files in f:
|
|
full_path = os.path.join(r, files)
|
|
try:
|
|
os.chmod(full_path, int(headphones.CONFIG.FILE_PERMISSIONS, 8))
|
|
except:
|
|
logger.error(f"Could not change permissions for `{full_path}`")
|
|
continue
|
|
|
|
def renameUnprocessedFolder(path, tag):
|
|
"""
|
|
Rename a unprocessed folder to a new unique name to indicate a certain
|
|
status.
|
|
"""
|
|
|
|
for i in itertools.count():
|
|
if i == 0:
|
|
new_path = "%s (%s)" % (path, tag)
|
|
else:
|
|
new_path = "%s (%s[%d])" % (path, tag, i)
|
|
|
|
if os.path.exists(new_path):
|
|
i += 1
|
|
else:
|
|
os.rename(path, new_path)
|
|
return
|
|
|
|
|
|
def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_original_folder=False):
|
|
logger.info('Force checking download folder for completed downloads')
|
|
|
|
ignored = 0
|
|
|
|
if album_dir:
|
|
folders = [album_dir]
|
|
else:
|
|
download_dirs = []
|
|
|
|
if dir:
|
|
download_dirs.append(dir)
|
|
if headphones.CONFIG.DOWNLOAD_DIR and not dir:
|
|
download_dirs.append(headphones.CONFIG.DOWNLOAD_DIR)
|
|
if headphones.CONFIG.SOULSEEK_DOWNLOAD_DIR and not dir:
|
|
download_dirs.append(headphones.CONFIG.SOULSEEK_DOWNLOAD_DIR)
|
|
if headphones.CONFIG.DOWNLOAD_TORRENT_DIR and not dir:
|
|
download_dirs.append(
|
|
headphones.CONFIG.DOWNLOAD_TORRENT_DIR.encode(headphones.SYS_ENCODING, 'replace'))
|
|
if headphones.CONFIG.BANDCAMP and not dir:
|
|
download_dirs.append(
|
|
headphones.CONFIG.BANDCAMP_DIR.encode(headphones.SYS_ENCODING, 'replace'))
|
|
|
|
# If DOWNLOAD_DIR and DOWNLOAD_TORRENT_DIR are the same, remove the duplicate to prevent us from trying to process the same folder twice.
|
|
download_dirs = list(set(download_dirs))
|
|
logger.debug('Post processing folders: %s', download_dirs)
|
|
|
|
# Get a list of folders in the download_dir
|
|
folders = []
|
|
|
|
for download_dir in download_dirs:
|
|
if not os.path.isdir(download_dir):
|
|
logger.warn('Directory %s does not exist. Skipping', download_dir)
|
|
continue
|
|
|
|
# Scan for subfolders
|
|
subfolders = os.listdir(download_dir)
|
|
ignored += helpers.path_filter_patterns(subfolders,
|
|
headphones.CONFIG.IGNORED_FOLDERS,
|
|
root=download_dir)
|
|
|
|
for folder in subfolders:
|
|
path_to_folder = os.path.join(download_dir, folder)
|
|
|
|
if os.path.isdir(path_to_folder):
|
|
subfolders = helpers.expand_subfolders(path_to_folder)
|
|
|
|
if expand_subfolders and subfolders is not None:
|
|
folders.extend(subfolders.decode(headphones.SYS_ENCODING, 'replace'))
|
|
else:
|
|
folders.append(path_to_folder.decode(headphones.SYS_ENCODING, 'replace'))
|
|
|
|
# Log number of folders
|
|
if folders:
|
|
logger.debug('Expanded post processing folders: %s', folders)
|
|
logger.info('Found %d folders to process (%d ignored).',
|
|
len(folders), ignored)
|
|
else:
|
|
logger.info('Found no folders to process. Aborting.')
|
|
return
|
|
|
|
# Parse the folder names to get artist album info
|
|
myDB = db.DBConnection()
|
|
|
|
for folder in folders:
|
|
folder_basename = os.path.basename(folder)
|
|
logger.info('Processing: %s', folder_basename)
|
|
|
|
# Attempt 1: First try to see if there's a match in the snatched table,
|
|
# then we'll try to parse the foldername.
|
|
# TODO: Iterate through underscores -> spaces, spaces -> dots,
|
|
# underscores -> dots (this might be hit or miss since it assumes all
|
|
# spaces/underscores came from sab replacing values
|
|
logger.debug('Attempting to find album in the snatched table')
|
|
snatched = myDB.action(
|
|
'SELECT AlbumID, Title, Kind, Status from snatched WHERE FolderName LIKE ?',
|
|
[folder_basename]).fetchone()
|
|
|
|
if snatched:
|
|
if headphones.CONFIG.KEEP_TORRENT_FILES and snatched['Kind'] == 'torrent' and snatched[
|
|
'Status'] == 'Processed':
|
|
logger.info(
|
|
'%s is a torrent folder being preserved for seeding and has already been processed. Skipping.',
|
|
folder_basename)
|
|
continue
|
|
else:
|
|
logger.info(
|
|
'Found a match in the database: %s. Verifying to make sure it is the correct album',
|
|
snatched['Title'])
|
|
verify(snatched['AlbumID'], folder, snatched['Kind'],
|
|
forced=True, keep_original_folder=keep_original_folder)
|
|
continue
|
|
|
|
# Attempt 2: strip release group id from filename
|
|
logger.debug('Attempting to extract release group from folder name')
|
|
|
|
try:
|
|
possible_rgid = folder_basename[-36:]
|
|
rgid = uuid.UUID(possible_rgid)
|
|
except:
|
|
rgid = possible_rgid = None
|
|
|
|
if rgid:
|
|
rgid = possible_rgid
|
|
release = myDB.action(
|
|
'SELECT ArtistName, AlbumTitle, AlbumID from albums WHERE AlbumID=?',
|
|
[rgid]).fetchone()
|
|
if release:
|
|
logger.info(
|
|
'Found a match in the database: %s - %s. Verifying to make sure it is the correct album',
|
|
release['ArtistName'], release['AlbumTitle'])
|
|
verify(release['AlbumID'], folder, forced=True,
|
|
keep_original_folder=keep_original_folder)
|
|
continue
|
|
else:
|
|
logger.info(
|
|
'Found a (possibly) valid Musicbrainz release group id in album folder name.')
|
|
verify(rgid, folder, forced=True,
|
|
keep_original_folder=keep_original_folder)
|
|
continue
|
|
|
|
# Attempt 3a: parse the folder name into a valid format
|
|
logger.debug('Attempting to extract name, album and year from folder name')
|
|
|
|
try:
|
|
name, album, year = helpers.extract_data(folder_basename)
|
|
except Exception:
|
|
name = album = year = None
|
|
|
|
if name and album:
|
|
release = myDB.action(
|
|
'SELECT AlbumID, ArtistName, AlbumTitle from albums WHERE ArtistName LIKE ? and AlbumTitle LIKE ?',
|
|
[name, album]).fetchone()
|
|
if release:
|
|
logger.info(
|
|
'Found a match in the database: %s - %s. Verifying to make sure it is the correct album',
|
|
release['ArtistName'], release['AlbumTitle'])
|
|
verify(release['AlbumID'], folder, forced=True, keep_original_folder=keep_original_folder)
|
|
continue
|
|
else:
|
|
logger.info('Querying MusicBrainz for the release group id for: %s - %s', name,
|
|
album)
|
|
try:
|
|
rgid = mb.findAlbumID(helpers.latinToAscii(name), helpers.latinToAscii(album))
|
|
except:
|
|
logger.error('Can not get release information for this album')
|
|
rgid = None
|
|
|
|
if rgid:
|
|
verify(rgid, folder, forced=True, keep_original_folder=keep_original_folder)
|
|
continue
|
|
else:
|
|
logger.info('No match found on MusicBrainz for: %s - %s', name, album)
|
|
|
|
# Attempt 3b: deduce meta data into a valid format
|
|
logger.debug('Attempting to extract name, album and year from metadata')
|
|
|
|
try:
|
|
name, album, year = helpers.extract_metadata(folder)
|
|
except Exception:
|
|
name = album = None
|
|
|
|
# Not found from meta data, check if there's a cue to split and try meta data again
|
|
kind = None
|
|
if headphones.CONFIG.CUE_SPLIT and not name and not album:
|
|
cue_folder = helpers.cue_split(folder, keep_original_folder=keep_original_folder)
|
|
if cue_folder:
|
|
try:
|
|
name, album, year = helpers.extract_metadata(cue_folder)
|
|
except Exception:
|
|
name = album = None
|
|
if name:
|
|
folder = cue_folder
|
|
if keep_original_folder:
|
|
keep_original_folder = False
|
|
kind = "cue_split"
|
|
elif folder != cue_folder:
|
|
cue_folder = os.path.split(cue_folder)[0]
|
|
shutil.rmtree(cue_folder)
|
|
|
|
if name and album:
|
|
release = myDB.action(
|
|
'SELECT AlbumID, ArtistName, AlbumTitle from albums WHERE ArtistName LIKE ? and AlbumTitle LIKE ?',
|
|
[name, album]).fetchone()
|
|
if release:
|
|
logger.info(
|
|
'Found a match in the database: %s - %s. Verifying to make sure it is the correct album',
|
|
release['ArtistName'], release['AlbumTitle'])
|
|
verify(release['AlbumID'], folder, Kind=kind, forced=True, keep_original_folder=keep_original_folder)
|
|
continue
|
|
else:
|
|
logger.info('Querying MusicBrainz for the release group id for: %s - %s', name,
|
|
album)
|
|
try:
|
|
rgid = mb.findAlbumID(helpers.latinToAscii(name), helpers.latinToAscii(album))
|
|
except:
|
|
logger.error('Can not get release information for this album')
|
|
rgid = None
|
|
|
|
if rgid:
|
|
verify(rgid, folder, Kind=kind, forced=True, keep_original_folder=keep_original_folder)
|
|
continue
|
|
else:
|
|
logger.info('No match found on MusicBrainz for: %s - %s', name, album)
|
|
|
|
# Attempt 4: Hail mary. Just assume the folder name is the album name
|
|
# if it doesn't have a separator in it
|
|
logger.debug('Attempt to extract album name by assuming it is the folder name')
|
|
|
|
if '-' not in folder_basename:
|
|
release = myDB.action(
|
|
'SELECT AlbumID, ArtistName, AlbumTitle from albums WHERE AlbumTitle LIKE ?',
|
|
[folder_basename]).fetchone()
|
|
if release:
|
|
logger.info(
|
|
'Found a match in the database: %s - %s. Verifying to make sure it is the correct album',
|
|
release['ArtistName'], release['AlbumTitle'])
|
|
verify(release['AlbumID'], folder, forced=True, keep_original_folder=keep_original_folder)
|
|
continue
|
|
else:
|
|
logger.info('Querying MusicBrainz for the release group id for: %s',
|
|
folder_basename)
|
|
try:
|
|
rgid = mb.findAlbumID(album=helpers.latinToAscii(folder_basename))
|
|
except:
|
|
logger.error('Can not get release information for this album')
|
|
rgid = None
|
|
|
|
if rgid:
|
|
verify(rgid, folder, forced=True, keep_original_folder=keep_original_folder)
|
|
continue
|
|
else:
|
|
logger.info('No match found on MusicBrainz for: %s - %s', name, album)
|
|
|
|
# Fail here
|
|
logger.info("Couldn't parse '%s' into any valid format. If adding "
|
|
"albums from another source, they must be in an 'Artist - Album "
|
|
"[Year]' format, or end with the musicbrainz release group id.",
|
|
folder_basename)
|