Compare commits

..
Author SHA1 Message Date
rembo10 c1edc9cde0 gh-workflow: rename, run on pull request, change python version 2022-01-23 14:34:10 +05:30
rembo10 ce98d0d6ca Ignore line length in flake8 2022-01-23 14:27:11 +05:30
rembo10 1bd7cc2ffd Whitespace fixes 2022-01-23 14:27:05 +05:30
rembo10 ad858576aa Add .flake8 configuration 2022-01-23 14:25:26 +05:30
rembo10 455b7d4940 Remove pylintrc 2022-01-23 14:25:26 +05:30
rembo10 cd14c3f4e2 travis -> github-actions 2022-01-23 14:25:26 +05:30
562 changed files with 45932 additions and 60235 deletions
+3
View File
@@ -0,0 +1,3 @@
[flake8]
exclude = .git,data,init-scripts,lib
ignore = E501
+29
View File
@@ -0,0 +1,29 @@
name: check
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.8, 3.9, 3.10]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 .
- name: Test with nosetests
run: |
nosetests
-25
View File
@@ -1,25 +0,0 @@
# Travis CI configuration file
# http://about.travis-ci.org/docs/
language: python
sudo: false
cache:
pip: true
directories:
- lib
python:
- "2.7"
install:
- pip install -r requirements-dev.txt
script:
- pep8 headphones
- pyflakes headphones
- nosetests
after_success:
- if [[ $TRAVIS_PYTHON_VERSION == "2.7" ]]; then coveralls; fi
-34
View File
@@ -1,39 +1,5 @@
# Changelog # Changelog
## v0.6.3
Released 26 May 2024
Highlights:
* Hotfix for searcher not returning results
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.6.2...v0.6.3).
## v0.6.2
Released 26 May 2024
Highlights:
* Added soulseek support
* Added bandcamp support
* Changes and dependency updates to work with Python >= 3.12
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.6.1...v0.6.2).
## v0.6.1
R eleased 26 November 2023
Highlights:
* Dependency updates to work with > Python 3.11
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.6.0...v0.6.1).
## v0.6.0
Released 13 November 2022
Highlights:
* Updated to python 3
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.20...v0.6.0).
## v0.5.20 ## v0.5.20
Released 15 October 2021 Released 15 October 2021
+2 -2
View File
@@ -17,8 +17,8 @@
import os import os
import sys import sys
if sys.version_info <= (3, 6): if sys.version_info <= (3, 5):
sys.stdout.write("Headphones requires Python >= 3.7\n") sys.stdout.write("Headphones requires Python >= 3.5\n")
sys.exit(1) sys.exit(1)
# Ensure lib added to path, before any other imports # Ensure lib added to path, before any other imports
+160 -274
View File
@@ -5,6 +5,7 @@
%> %>
<%def name="headerIncludes()"> <%def name="headerIncludes()">
<div id="subhead_container"> <div id="subhead_container">
<div id="back_to_previous_link"> <div id="back_to_previous_link">
<a href="artistPage?ArtistID=${album['ArtistID']}" class="back">&laquo; Back to ${album['ArtistName']}</a> <a href="artistPage?ArtistID=${album['ArtistID']}" class="back">&laquo; Back to ${album['ArtistName']}</a>
@@ -12,47 +13,44 @@
<div id="subhead_menu"> <div id="subhead_menu">
<a id="menu_link_delete" href="deleteAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}"><i class="fa fa-trash-o"></i> Delete Album</a> <a id="menu_link_delete" href="deleteAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}"><i class="fa fa-trash-o"></i> Delete Album</a>
%if album['Status'] == 'Skipped' or album['Status'] == 'Ignored': %if album['Status'] == 'Skipped' or album['Status'] == 'Ignored':
<a id="menu_link_wanted" href="#" class="album-action" data-action="queueAlbum" data-album-id="${album['AlbumID']}" data-artist-id="${album['ArtistID']}" data-new="False" data-success-msg="'${album['AlbumTitle']}' added to queue"><i class="fa fa-heart"></i> Mark Album as Wanted</a> <a id="menu_link_wanted" href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}&new=False', $(this),true)" data-success="'${album['AlbumTitle']}' added to queue"><i class="fa fa-heart"></i> Mark Album as Wanted</a>
%elif album['Status'] == 'Wanted': %elif album['Status'] == 'Wanted':
<a id="menu_link_check" href="#" class="album-action" data-action="queueAlbum" data-album-id="${album['AlbumID']}" data-artist-id="${album['ArtistID']}" data-new="True" data-success-msg="Forced checking successful"><i class="fa fa-search"></i> Force Check</a> <a id="menu_link_check" href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}&new=True', $(this));" data-success="Forced checking successful"><i class="fa fa-search"></i> Force Check</a>
<a id="menu_link_skipped" href="#" class="album-action" data-action="unqueueAlbum" data-album-id="${album['AlbumID']}" data-artist-id="${album['ArtistID']}" data-success-msg="'${album['AlbumTitle']}' marked as Skipped"><i class="fa fa-step-forward"></i> Mark Album as Skipped</a> <a id="menu_link_skipped" href="javascript:void(0)" onclick="doAjaxCall('unqueueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}', $(this),true);" data-success="'${album['AlbumTitle']}' marked as Skipped"><i class="fa fa-step-forward"></i> Mark Album as Skipped</a>
%else: %else:
<a id="menu_link_retry" href="#" class="album-action" data-action="queueAlbum" data-album-id="${album['AlbumID']}" data-artist-id="${album['ArtistID']}" data-new="False" data-success-msg="Retrying the same version of '${album['AlbumTitle']}'"><i class="fa fa-refresh"></i> Retry Download</a> <a id="menu_link_retry" href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}&new=False', $(this),true);" data-success="Retrying the same version of '${album['AlbumTitle']}'"><i class="fa fa-refresh"></i> Retry Download</a>
<a id="menu_link_new" href="#" class="album-action" data-action="queueAlbum" data-album-id="${album['AlbumID']}" data-artist-id="${album['ArtistID']}" data-new="True" data-success-msg="Looking for a new version of '${album['AlbumTitle']}'"><i class="fa fa-download"></i> Try New Version</a> <a id="menu_link_new" href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}&new=True', $(this),true);" data-success="Looking for a new version of '${album['AlbumTitle']}'"><i class="fa fa-download"></i> Try New Version</a>
%endif %endif
<a class="menu_link_edit" id="album_chooser" href="javascript:void(0)"><i class="fa fa-pencil"></i> Choose Alternate Release</a>
<a class="menu_link_edit dialog-trigger" id="album_chooser" href="#" data-dialog-id="dialog"><i class="fa fa-pencil"></i> Choose Alternate Release</a> <div id="dialog" title="Choose an Alternate Release" style="display:none" class="configtable">
<div id="dialog" title="Choose an Alternate Release" style="display:none" class="configtable"> <div class="links">
<div class="links"> <%
<% alternate_albums = myDB.select("SELECT * from allalbums WHERE AlbumID=? ORDER BY ReleaseDate ASC", [album['AlbumID']])
alternate_albums = myDB.select("SELECT * from allalbums WHERE AlbumID=? ORDER BY ReleaseDate ASC", [album['AlbumID']]) %>
%> %if not alternate_albums:
%if not alternate_albums: <p>No alternate releases found. Try refreshing the artist (if the artist is being refreshed, please wait until it's finished)</p>
<p>No alternate releases found. Try refreshing the artist (if the artist is being refreshed, please wait until it's finished)</p> <h2><a id="refresh_artist" onclick="doAjaxCall('refreshArtist?ArtistID=${album['ArtistID']}', $(this)), true" href="javascript:void(0)" data-success="'${album['ArtistName']}' is being refreshed">Refresh Artist</a></h2>
<h2><a id="refresh_artist_btn" href="#" class="album-action" data-action="refreshArtist" data-artist-id="${album['ArtistID']}" data-success-msg="'${album['ArtistName']}' is being refreshed">Refresh Artist</a></h2> %else:
%else: %for alternate_album in alternate_albums:
%for alternate_album in alternate_albums: <%
<% track_count = len(myDB.select("SELECT * from alltracks WHERE ReleaseID=?", [alternate_album['ReleaseID']]))
track_count = len(myDB.select("SELECT * from alltracks WHERE ReleaseID=?", [alternate_album['ReleaseID']])) mb_link = "http://musicbrainz.org/release/" + alternate_album['ReleaseID']
mb_link = "http://musicbrainz.org/release/" + alternate_album['ReleaseID'] have_track_count = len(myDB.select("SELECT * from alltracks WHERE ReleaseID=? AND Location IS NOT NULL", [alternate_album['ReleaseID']]))
have_track_count = len(myDB.select("SELECT * from alltracks WHERE ReleaseID=? AND Location IS NOT NULL", [alternate_album['ReleaseID']])) if alternate_album['AlbumID'] == alternate_album['ReleaseID']:
if alternate_album['AlbumID'] == alternate_album['ReleaseID']: alternate_album_name = "Headphones Default Release (" + str(alternate_album['ReleaseDate']) + ") [" + str(have_track_count) + "/" + str(track_count) + " tracks]"
alternate_album_name = "Headphones Default Release (" + str(alternate_album['ReleaseDate']) + ") [" + str(have_track_count) + "/" + str(track_count) + " tracks]" else:
else: alternate_album_name = alternate_album['AlbumTitle'] + " (" + alternate_album['ReleaseCountry'] + ", " + str(alternate_album['ReleaseDate']) + ", " + alternate_album['ReleaseFormat'] + ") [" + str(have_track_count) + "/" + str(track_count) + " tracks]"
alternate_album_name = alternate_album['AlbumTitle'] + " (" + alternate_album['ReleaseCountry'] + ", " + str(alternate_album['ReleaseDate']) + ", " + alternate_album['ReleaseFormat'] + ") [" + str(have_track_count) + "/" + str(track_count) + " tracks]"
%>
%> <a href="javascript:void(0)" onclick="doAjaxCall('switchAlbum?AlbumID=${album['AlbumID']}&ReleaseID=${alternate_album['ReleaseID']}', $(this), 'table');" data-success="Switched release to: ${alternate_album_name}">${alternate_album_name}</a><a href="${mb_link}" target="_blank">MB</a><br>
<a href="#" class="album-action alternate-release-switch" data-action="switchAlbum" data-album-id="${album['AlbumID']}" data-release-id="${alternate_album['ReleaseID']}" data-success-msg="Switched release to: ${alternate_album_name}">${alternate_album_name}</a><a href="${mb_link}" target="_blank" class="external-link">MB</a><br> %endfor
%endfor %endif
%endif </div>
</div> </div>
</div> <a class="menu_link_edit" id="edit_search_term" href="javascript:void(0)"><i class="fa fa-pencil"></i> Edit Search Term</a>
<a class="menu_link_edit dialog-trigger" id="edit_search_term" href="#" data-dialog-id="dialog2"><i class="fa fa-pencil"></i> Edit Search Term</a>
<div id="dialog2" title="Enter your own search term for this album" style="display:none" class="configtable"> <div id="dialog2" title="Enter your own search term for this album" style="display:none" class="configtable">
<form action="editSearchTerm" method="GET" id="editSearchTermForm"> <form action="editSearchTerm" method="GET" id="editSearchTerm">
<input type="hidden" name="AlbumID" value="${album['AlbumID']}"> <input type="hidden" name="AlbumID" value="${album['AlbumID']}">
<div class="row"> <div class="row">
<% <%
@@ -63,11 +61,10 @@
%> %>
<input type="text" value="${search_term}" name="SearchTerm" size="40" /> <input type="text" value="${search_term}" name="SearchTerm" size="40" />
</div> </div>
<button type="submit" class="album-action-submit" data-action="editSearchTerm" data-success-msg="Search term updated">Save changes</button> <input type="button" value="Save changes" onclick="doAjaxCall('editSearchTerm',$(this),'tabs',true);return false;" data-success="Search term updated"/>
</form> </form>
</div> </div>
<a class="menu_link_edit" id="choose_specific_download" href="javascript:void(0)" onclick="getAvailableDownloads()"><i class="fa fa-search"></i> Choose Specific Download</a>
<a class="menu_link_edit dialog-trigger" id="choose_specific_download" href="#" data-dialog-id="choose_specific_download_dialog" data-action="getAvailableDownloads"><i class="fa fa-search"></i> Choose Specific Download</a>
<div id="choose_specific_download_dialog" title="Choose a specific download for this album" style="display:none" class="configtable"> <div id="choose_specific_download_dialog" title="Choose a specific download for this album" style="display:none" class="configtable">
<table class="display" id="downloads_table"> <table class="display" id="downloads_table">
<thead> <thead>
@@ -91,7 +88,7 @@
<div class="table_wrapper"> <div class="table_wrapper">
<div id="albumheader" class="clearfix"> <div id="albumheader" class="clearfix">
<div id="albumImg"> <div id="albumImg">
<img alt="${album['AlbumTitle']} album art" class="albumArt" src="artwork/album/${album['AlbumID']}"> <img height="200" alt="" class="albumArt" src="artwork/album/${album['AlbumID']}">
</div> </div>
<h1 id="albumname"> <h1 id="albumname">
@@ -109,6 +106,7 @@
albumduration = helpers.convert_milliseconds(totalduration) albumduration = helpers.convert_milliseconds(totalduration)
except: except:
albumduration = 'n/a' albumduration = 'n/a'
%> %>
<div class="albuminfo"> <div class="albuminfo">
<div id="albumInfo"></div> <div id="albumInfo"></div>
@@ -189,290 +187,178 @@
</%def> </%def>
<%def name="headIncludes()"> <%def name="headIncludes()">
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
<link rel="stylesheet" href="interfaces/default/css/data_table.css"> <link rel="stylesheet" href="interfaces/default/css/data_table.css">
</%def> </%def>
<%def name="javascriptIncludes()"> <%def name="javascriptIncludes()">
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
<script src="js/libs/jquery.dataTables.min.js"></script> <script src="js/libs/jquery.dataTables.min.js"></script>
<script> <script>
// Define a global object for page-specific functions to avoid polluting global scope directly function getAlbumInfo() {
var AlbumPage = AlbumPage || {};
AlbumPage.search_results = []; // Moved to page-specific scope
AlbumPage.getAlbumInfo = function() {
var id = "${album['AlbumID']}"; var id = "${album['AlbumID']}";
var elem = $("#albumInfo"); var elem = $("#albumInfo");
// Assuming getInfo is defined in common.js getInfo(elem,id,'album');
if (typeof getInfo === 'function') { }
getInfo(elem,id,'album');
} else {
console.warn("getInfo function not found. Album info might not be loaded.");
}
};
AlbumPage.initDialogs = function() { function initThisPage() {
// General handler for opening dialogs based on data-dialog-id $('#album_chooser').click(function() {
$(document).on('click', '.dialog-trigger', function(e) { $('#dialog').dialog({
e.preventDefault(); width: 500,
var dialogId = $(this).data('dialog-id'); maxHeight: 500
var $dialog = $('#' + dialogId);
if ($dialog.length) {
// Specific logic for choose_specific_download_dialog before opening
if (dialogId === 'choose_specific_download_dialog') {
AlbumPage.getAvailableDownloads();
} else {
$dialog.dialog({
width: 500,
maxHeight: 500,
// Add close event to clear dynamic content if any
close: function() {
if (dialogId === 'downloads_table_body') {
$('#downloads_table_body').empty(); // Clear table content on close
// Re-initialize DataTable if needed on next open, or destroy it here
}
}
});
}
}
});
// Specific handler for refreshing artist from alternate releases dialog
$(document).on('click', '#refresh_artist_btn', function(e) {
e.preventDefault();
var $this = $(this);
// Assuming doAjaxCall is defined in common.js
if (typeof doAjaxCall === 'function') {
doAjaxCall($this.data('action') + '?ArtistID=' + $this.data('artist-id'), $this, true, $this.data('success-msg'));
}
$('#dialog').dialog("close"); // Close dialog after action
});
};
AlbumPage.initDataTables = function() {
// Initialize track_table
$('#track_table').DataTable({ // Use .DataTable() for new versions of DataTables
"ordering": false, // aaSorting -> ordering
"searching": false, // bFilter -> searching
"info": false, // bInfo -> info
"paging": false, // bPaginate -> paging
"destroy": true // bDestroy -> destroy
});
};
AlbumPage.getAvailableDownloads = function() {
AlbumPage.ShowSpinner();
var albumId = "${album['AlbumID']}";
$.getJSON("choose_specific_download?AlbumID=" + albumId, function(data) {
AlbumPage.loader.remove(); // Assuming loader is attached to AlbumPage scope now
AlbumPage.feedback.fadeOut(); // Assuming feedback is attached to AlbumPage scope now
AlbumPage.search_results = data; // Store results
// Clear previous content
$('#downloads_table_body').empty();
for( var i = 0, len = data.length; i < len; i++ ) {
$('#downloads_table_body').append(
'<tr>' +
'<td id="title"><a href="#" class="download-specific-release-link" data-index="' + i + '">' + data[i].title + '</a></td>' +
'<td id="size"><span title="'+data[i].size+'"></span>' + (data[i].size / (1024*1024)).toFixed(2) + ' MB</td>' +
'<td id="provider">' + data[i].provider + '</td>' +
'<td id="kind">' + data[i].kind + '</td>' +
'<td id="matches">' + data[i].matches + '</td>' +
'</tr>'
);
}
// Destroy and re-initialize the DataTable for downloads
if ($.fn.DataTable.isDataTable('#downloads_table')) {
$('#downloads_table').DataTable().destroy();
}
$('#downloads_table').DataTable({
"columnDefs": [ // aoColumns -> columnDefs
{ "orderable": false, "targets": [0, 2, 3] }, // Disable ordering for Title, Provider, Kind
{ "type": "title-numeric", "targets": 1 }, // sType -> type
{ "type": "string", "targets": 4 }
],
"order": [[ 4, 'desc']], // aaSorting -> order
"searching": false,
"info": false,
"paging": false,
"destroy": true // Important for re-initialization
}); });
return false;
});
$('#edit_search_term').click(function() {
$('#dialog2').dialog({
width: 500,
maxHeight: 500
});
return false;
});
$('#refresh_artist').click(function() {
$('#dialog').dialog("close");
});
initActions();
setTimeout(function(){
initFancybox();
}, 1000);
$('#track_table').dataTable({
"aaSorting": [],
"bFilter": false,
"bInfo": false,
"bPaginate": false,
"bDestroy": true
});
};
function getAvailableDownloads() {
ShowSpinner();
$.getJSON("choose_specific_download?AlbumID=${album['AlbumID']}", function(data) {
loader.remove();
feedback.fadeOut();
search_results = data
for( var i = 0, len = data.length; i < len; i++ ) {
$('#downloads_table_body').append('<tr><td id="title"><a href="javascript:void(0)" onclick="downloadSpecificRelease('+i+')">'+data[i].title+'</a></td><td id="size"><span title='+data[i].size+'></span>'+(data[i].size / (1024*1024)).toFixed(2)+' MB</td><td id="provider">'+data[i].provider+'</td><td id="kind">'+data[i].kind+'</td><td id="matches">'+data[i].matches+'</td></tr>');
}
$('#downloads_table').dataTable({
"aoColumns": [
null,
{"sType": "title-numeric"},
null,
null,
{"sType": "string"}
],
"aaSorting": [[ 4, 'desc']],
"bFilter": false,
"bInfo": false,
"bPaginate": false,
"bDestroy": true
});
$("#choose_specific_download_dialog").dialog({ $("#choose_specific_download_dialog").dialog({
width: "80%", width: "80%",
maxHeight: 500, maxHeight: 500
modal: true // Added modal to make it more common practice
}); });
return false;
}); });
}; }
AlbumPage.downloadSpecificRelease = function(i){ function downloadSpecificRelease(i){
var release = AlbumPage.search_results[i]; // Get from stored results
var url = "download_specific_release?AlbumID=${album['AlbumID']}" + title = search_results[i].title
"&title=" + encodeURIComponent(release.title) + size = search_results[i].size
"&size=" + release.size + url = search_results[i].url
"&url=" + encodeURIComponent(release.url) + provider = search_results[i].provider
"&provider=" + encodeURIComponent(release.provider) + kind = search_results[i].kind
"&kind=" + encodeURIComponent(release.kind);
AlbumPage.ShowSpinner(); ShowSpinner();
$.getJSON(url, function(data) { $.getJSON("download_specific_release?AlbumID=${album['AlbumID']}&title="+title+"&size="+size+"&url="+url+"&provider="+provider+"&kind=" + kind, function(data) {
AlbumPage.loader.remove(); loader.remove();
AlbumPage.feedback.fadeOut(); feedback.fadeOut();
// Assuming refreshSubmenu is defined globally or in common.js refreshSubmenu();
if (typeof refreshSubmenu === 'function') {
refreshSubmenu();
}
$("#choose_specific_download_dialog").dialog("close"); $("#choose_specific_download_dialog").dialog("close");
}); });
}; }
AlbumPage.ShowSpinner = function() { function ShowSpinner() {
AlbumPage.feedback = $("#ajaxMsg"); // Assign to AlbumPage scope feedback = $("#ajaxMsg");
var update = $("#updatebar"); update = $("#updatebar");
if ( update.is(":visible") ) { if ( update.is(":visible") ) {
var height = update.height() + 35; var height = update.height() + 35;
AlbumPage.feedback.css("bottom",height + "px"); feedback.css("bottom",height + "px");
} else { } else {
AlbumPage.feedback.removeAttr("style"); feedback.removeAttr("style");
} }
AlbumPage.loader = $("<i class='fa fa-refresh fa-spin'></i>"); // Assign to AlbumPage scope loader = $("<i class='fa fa-refresh fa-spin'></i>");
AlbumPage.feedback.prepend(AlbumPage.loader); feedback.prepend(loader);
AlbumPage.feedback.fadeIn(); feedback.fadeIn();
}; }
AlbumPage.loadingMessage = false; var loadingMessage = false;
AlbumPage.spinner_active = false; var spinner_active = false;
AlbumPage.loadingtext_active = false; var loadingtext_active = false;
AlbumPage.refreshInterval = null; // Initialize as null var refreshInterval;
AlbumPage.wasLoading = false; var wasLoading = false;
AlbumPage.x = 0; var x = 0;
AlbumPage.checkAlbumStatus = function() { function checkAlbumStatus() {
$.getJSON("getAlbumjson?AlbumID=${album['AlbumID']}", function(data) { $.getJSON("getAlbumjson?AlbumID=${album['AlbumID']}", function(data) {
if (data['Status'] === "Loading"){ if (data['Status'] == "Loading"){
AlbumPage.wasLoading = true; wasLoading = true;
$('#albumnamelink').text(data["AlbumTitle"]); $('#albumnamelink').text(data["AlbumTitle"]);
$('#artistnamelink').text(data["ArtistName"]); $('#artistnamelink').text(data["ArtistName"]);
if (AlbumPage.loadingMessage === false){ if (loadingMessage == false){
$("#ajaxMsg").after( "<div id='ajaxMsg2' class='ajaxMsg'></div>" ); $("#ajaxMsg").after( "<div id='ajaxMsg2' class='ajaxMsg'></div>" );
// Assuming showArtistMsg is defined globally or in common.js showArtistMsg("Getting album information");
if (typeof showArtistMsg === 'function') { loadingMessage = true;
showArtistMsg("Getting album information");
}
AlbumPage.loadingMessage = true;
} }
if (AlbumPage.spinner_active === false){ if (spinner_active == false){
$('#albumname').prepend('<i class="fa fa-refresh fa-spin" id="albumnamespinner"></i>'); $('#albumname').prepend('<i class="fa fa-refresh fa-spin" id="albumnamespinner"></i>')
AlbumPage.spinner_active = true; spinner_active = true;
} }
if (AlbumPage.loadingtext_active === false){ if (loadingtext_active == false){
$('#albumname').append('<h3 id="loadingtext"><i>(Album information is currently being loaded)</i></h3>'); $('#albumname').append('<h3 id="loadingtext"><i>(Album information is currently being loaded)</i></h3>')
AlbumPage.loadingtext_active = true; loadingtext_active = true;
} }
} else { }
AlbumPage.x++; else{
if (AlbumPage.x === 10 || AlbumPage.wasLoading || $("#artistname").text().trim() === "Loading") { // Combined conditions if (++x === 10) {
if (AlbumPage.refreshInterval) { // Clear only if interval is set clearInterval(refreshInterval);
clearInterval(AlbumPage.refreshInterval); }
} var sts = $("#artistname").text().trim();
location.reload(); // Reload the page to show updated status if (wasLoading == true || sts == "Loading"){
location.reload();
$('#albumnamespinner').remove()
$('#loadingtext').remove()
$('#ajaxMsg2').remove()
spinner_active = false
loadingtext_active = false
loadingMessage = false
} }
$('#albumnamespinner').remove();
$('#loadingtext').remove();
$('#ajaxMsg2').remove();
AlbumPage.spinner_active = false;
AlbumPage.loadingtext_active = false;
AlbumPage.loadingMessage = false;
} }
}); });
}; }
// jQuery DataTables custom sorting
jQuery.extend( jQuery.fn.dataTableExt.oSort, { jQuery.extend( jQuery.fn.dataTableExt.oSort, {
"title-numeric-pre": function ( a ) { "title-numeric-pre": function ( a ) {
// Ensure it handles cases where title attribute might be missing or different var x = a.match(/title="*(-?[0-9\.]+)/)[1];
var match = a.match(/title="*(-?[0-9\.]+)/); return parseFloat( x );
return match ? parseFloat( match[1] ) : -Infinity; // Return a safe default
}, },
"title-numeric-asc": function ( a, b ) { "title-numeric-asc": function ( a, b ) {
return ((a < b) ? -1 : ((a > b) ? 1 : 0)); return ((a < b) ? -1 : ((a > b) ? 1 : 0));
}, },
"title-numeric-desc": function ( a, b ) { "title-numeric-desc": function ( a, b ) {
return ((a < b) ? 1 : ((a > b) ? -1 : 0)); return ((a < b) ? 1 : ((a > b) ? -1 : 0));
} }
}); } );
$(document).ready(function() { $(document).ready(function() {
AlbumPage.getAlbumInfo(); getAlbumInfo();
AlbumPage.initDialogs(); // Initialize dialog triggers initThisPage();
AlbumPage.initDataTables(); // Initialize DataTables checkAlbumStatus();
// Do not use initActions() here unless it's strictly needed and modernized itself. refreshInterval = setInterval(function(){
// setTimeout for fancybox is likely not needed if elements are ready or use delegated events. checkAlbumStatus();
// If fancybox is for dynamic content, initialize it after content is loaded.
// initFancybox(); // Re-evaluate if this is still needed or how it's used.
// Event handler for album actions
// Delegated to #subhead_menu as these links are within it
$('#subhead_menu').on('click', '.album-action', function(e) {
e.preventDefault();
var $this = $(this);
var action = $this.data('action');
var albumId = $this.data('album-id');
var artistId = $this.data('artist-id');
var newStatus = $this.data('new'); // Boolean 'true' or 'false'
var releaseId = $this.data('release-id');
var successMsg = $this.data('success-msg');
var url = action + '?AlbumID=' + albumId;
if (artistId) url += '&ArtistID=' + artistId;
if (newStatus !== undefined) url += '&new=' + newStatus;
if (releaseId) url += '&ReleaseID=' + releaseId;
// Assuming doAjaxCall is defined globally or in common.js
if (typeof doAjaxCall === 'function') {
doAjaxCall(url, $this, true, successMsg);
} else {
console.error("doAjaxCall function is not defined!");
}
});
// Event handler for specific download links within the dialog
// Delegated to #downloads_table_body as these links are dynamically added
$('#downloads_table_body').on('click', '.download-specific-release-link', function(e) {
e.preventDefault();
var index = $(this).data('index');
AlbumPage.downloadSpecificRelease(index);
});
// Event handler for form submission within dialogs
// Delegated to the specific form's ID
$('#editSearchTermForm').on('submit', function(e) {
e.preventDefault(); // Prevent default form submission
var $this = $(this);
var action = $this.attr('action'); // Get action from form
var formData = $this.serialize(); // Serialize form data
var successMsg = $this.find('.album-action-submit').data('success-msg');
// Assuming doAjaxCall is defined globally or in common.js
if (typeof doAjaxCall === 'function') {
// Pass form data instead of element, specify 'form' type if common.js handles it
doAjaxCall(action + '?' + formData, $this.find('.album-action-submit'), true, successMsg);
}
$('#dialog2').dialog("close"); // Close dialog after submission
});
// Start checking album status periodically
AlbumPage.checkAlbumStatus();
AlbumPage.refreshInterval = setInterval(function(){
AlbumPage.checkAlbumStatus();
}, 3000); }, 3000);
}); });
+149 -289
View File
@@ -8,35 +8,31 @@
<%def name="headerIncludes()"> <%def name="headerIncludes()">
<div id="subhead_container"> <div id="subhead_container">
<div id="subhead_menu"> <div id="subhead_menu">
<a id="menu_link_refresh" href="#" class="artist-action" data-action="refreshArtist" data-artist-id="${artist['ArtistID']}" data-success-msg="${artist['ArtistName']} refreshed"><i class="fa fa-refresh"></i> Refresh Artist</a> <a id="menu_link_refresh" onclick="doSimpleAjaxCall('refreshArtist?ArtistID=${artist['ArtistID']}')" href="javascript:void(0)"><i class="fa fa-refresh"></i> Refresh Artist</a>
<a id="menu_link_delete" href="deleteArtist?ArtistID=${artist['ArtistID']}"><i class="fa fa-trash-o"></i> Delete Artist</a> <a id="menu_link_delete" href="deleteArtist?ArtistID=${artist['ArtistID']}"><i class="fa fa-trash-o"></i> Delete Artist</a>
<a id="menu_link_scan" href="#" class="artist-action" data-action="scanArtist" data-artist-id="${artist['ArtistID']}" data-success-msg="'${artist['ArtistName']}' was scanned"><i class="fa fa-refresh"></i> Scan Artist</a> <a id="menu_link_scan" onclick="doAjaxCall('scanArtist?ArtistID=${artist['ArtistID']}', $(this)),'table'" href="javascript:void(0)" data-success="'${artist['ArtistName']}' was scanned"><i class="fa fa-refresh"></i> Scan Artist</a>
%if artist['Status'] == 'Paused': %if artist['Status'] == 'Paused':
<a id="menu_link_resume" href="#" class="artist-action" data-action="resumeArtist" data-artist-id="${artist['ArtistID']}" data-success-msg="${artist['ArtistName']} resumed"><i class="fa fa-play"></i> Resume Artist</a> <a id="menu_link_resume" href="javascript:void(0)" onclick="doAjaxCall('resumeArtist?ArtistID=${artist['ArtistID']}',$(this),true)" data-success="${artist['ArtistName']} resumed"><i class="fa fa-play"></i> Resume Artist</a>
%else: %else:
<a id="menu_link_pauze" href="#" class="artist-action" data-action="pauseArtist" data-artist-id="${artist['ArtistID']}" data-success-msg="${artist['ArtistName']} paused"><i class="fa fa-pause"></i> Pause Artist</a> <a id="menu_link_pauze" href="javascript:void(0)" onclick="doAjaxCall('pauseArtist?ArtistID=${artist['ArtistID']}',$(this),true)" data-success="${artist['ArtistName']} paused"><i class="fa fa-pause"></i> Pause Artist</a>
%endif %endif
%if artist['IncludeExtras']: %if artist['IncludeExtras']:
<a id="menu_link_removeextra" href="#" class="artist-action" data-action="removeExtras" data-artist-id="${artist['ArtistID']}" data-artist-name="${artist['ArtistName']}" data-success-msg="Extras removed for ${artist['ArtistName']}"><i class="fa fa-minus"></i> Remove Extras</a> <a id="menu_link_removeextra" href="javascript:void(0)" onclick="doAjaxCall('removeExtras?ArtistID=${artist['ArtistID']}&ArtistName=${artist['ArtistName']}',$(this),'submenu&table')" data-success="Extras removed for ${artist['ArtistName']}"><i class="fa fa-minus"></i> Remove Extras</a>
<a class="menu_link_edit dialog-trigger" id="menu_link_modifyextra" href="#" data-dialog-id="dialog"><i class="fa fa-pencil"></i> Modify Extras</a> <a class="menu_link_edit" id="menu_link_modifyextra" href="javascript:void(0)"><i class="fa fa-pencil"></i> Modify Extras</a>
%else: %else:
<a id="menu_link_getextra" href="#" class="dialog-trigger" data-dialog-id="dialog"><i class="fa fa-plus"></i> Get Extras</a> <a id="menu_link_getextra" href="javascript:void(0)"><i class="fa fa-plus"></i> Get Extras</a>
%endif %endif
<div id="dialog" title="Choose Which Extras to Fetch" style="display:none" class="configtable">
<div id="dialog" title="Choose Which Extras to Fetch" style="display:none" class="configtable"> <form action="getExtras" method="get" class="form">
<form action="getExtras" method="get" id="getExtrasForm"> <input type="hidden" name="ArtistID" value="${artist['ArtistID']}">
<input type="hidden" name="ArtistID" value="${artist['ArtistID']}"> <input type="hidden" name="newstyle" value="true">
<input type="hidden" name="newstyle" value="true"> %for extra in extras:
%for extra in extras: <input type="checkbox" id="${extra}" name="${extra}" value="1" ${extras[extra]} />${string.capwords(extra)}<br>
<input type="checkbox" id="extra_${extra}" name="${extra}" value="1" ${extras[extra]} /> %endfor
<label for="extra_${extra}">${string.capwords(extra)}</label><br> <br>
%endfor <input id="submit" type="submit" value="Fetch Extras">
<br> </form>
<button type="submit">Fetch Extras</button> </div>
</form>
</div>
</div> </div>
</div> </div>
<a href="home" class="back">&laquo; Back to overview</a> <a href="home" class="back">&laquo; Back to overview</a>
@@ -45,7 +41,7 @@
<%def name="body()"> <%def name="body()">
<div id="artistheader" class="clearfix"> <div id="artistheader" class="clearfix">
<div id="artistImg"> <div id="artistImg">
<img id="artistImage" class="albumArt" alt="${artist['ArtistName']} artist image" src="artwork/artist/${artist['ArtistID']}"/> <img id="artistImage" class="albumArt" alt="" src="artwork/artist/${artist['ArtistID']}"/>
</div> </div>
<h1 id="artistname"> <h1 id="artistname">
<a href="http://musicbrainz.org/artist/${artist['ArtistID']}" id="artistnamelink">${artist['ArtistName']}</a> <a href="http://musicbrainz.org/artist/${artist['ArtistID']}" id="artistnamelink">${artist['ArtistName']}</a>
@@ -53,10 +49,10 @@
<div id="artistBio"></div> <div id="artistBio"></div>
</div> </div>
<ul id="artistCalendar" style="display:none;"></ul> <ul id="artistCalendar" style="display:none;"></ul>
<form action="markAlbums" method="get" id="markAlbumsForm"> <form action="markAlbums" method="get" id="markAlbums">
<input type="hidden" name="ArtistID" value=${artist['ArtistID']}> <input type="hidden" name="ArtistID" value=${artist['ArtistID']}>
<div id="markalbum">Mark selected albums as <div id="markalbum">Mark selected albums as
<select name="action" id="markAlbumsActionSelect"> <select name="action" onChange="doAjaxCall('markAlbums',$(this),'table',true);" data-error="You didn't select any albums">
<option disabled="disabled" selected="selected">Choose...</option> <option disabled="disabled" selected="selected">Choose...</option>
<option value="Wanted">Wanted</option> <option value="Wanted">Wanted</option>
<option value="WantedNew">Wanted (new only)</option> <option value="WantedNew">Wanted (new only)</option>
@@ -64,12 +60,12 @@
<option value="Ignored">Ignored</option> <option value="Ignored">Ignored</option>
<option value="Downloaded">Downloaded</option> <option value="Downloaded">Downloaded</option>
</select> </select>
<button type="submit" style="display:none;"></button> <%-- Hidden submit to allow form submission via JS --%> <input type="hidden" value="Go">
</div> </div>
<table class="display" id="album_table"> <table class="display" id="album_table">
<thead> <thead>
<tr> <tr>
<th id="select"><input type="checkbox" class="select-all-checkbox" aria-label="Select all albums" /></th> <th id="select"><input type="checkbox" onClick="toggle(this)" /></th>
<th id="albumart"></th> <th id="albumart"></th>
<th id="albumname">Name</th> <th id="albumname">Name</th>
<th id="reldate">Date</th> <th id="reldate">Date</th>
@@ -125,24 +121,24 @@
%> %>
<tr class="grade${grade}"> <tr class="grade${grade}">
<td id="select"><input type="checkbox" name="${album['AlbumID']}" class="checkbox album-checkbox" /></td> <td id="select"><input type="checkbox" name="${album['AlbumID']}" class="checkbox" /></td>
<td id="albumart"><img class="albumArtnostretch" id="thumb_${album['AlbumID']}" alt="${album['AlbumTitle']} thumbnail" src="artwork/thumbs/album/${album['AlbumID']}"></td> <td id="albumart"><img class="albumArtnostretch" id="${album['AlbumID']}" src="artwork/thumbs/album/${album['AlbumID']}" height="64" width="64"></td>
<td id="albumname"><a href="albumPage?AlbumID=${album['AlbumID']}">${album['AlbumTitle']}</a></td> <td id="albumname"><a href="albumPage?AlbumID=${album['AlbumID']}">${album['AlbumTitle']}</a></td>
<td id="reldate">${album['ReleaseDate']}</td> <td id="reldate">${album['ReleaseDate']}</td>
<td id="type">${album['Type']}</td> <td id="type">${album['Type']}</td>
<td id="score">${album['CriticScore']}/${album['UserScore']}</td> <td id="score">${album['CriticScore']}/${album['UserScore']}</td>
<td id="status">${album['Status']} <td id="status">${album['Status']}
%if album['Status'] == 'Skipped' or album['Status'] == 'Ignored': %if album['Status'] == 'Skipped' or album['Status'] == 'Ignored':
[<a href="#" class="album-action-inline" data-action="queueAlbum" data-album-id="${album['AlbumID']}" data-artist-id="${album['ArtistID']}" data-success-msg="'${album['AlbumTitle']}' added to Wanted list">want</a>] [<a href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}',$(this),'table')" data-success="'${album['AlbumTitle']}' added to Wanted list">want</a>]
%elif (album['Status'] == 'Wanted' or album['Status'] == 'Wanted Lossless'): %elif (album['Status'] == 'Wanted' or album['Status'] == 'Wanted Lossless'):
[<a href="#" class="album-action-inline" data-action="unqueueAlbum" data-album-id="${album['AlbumID']}" data-artist-id="${album['ArtistID']}" data-success-msg="'${album['AlbumTitle']}' skipped">skip</a>] [<a href="#" class="album-action-inline" data-action="queueAlbum" data-album-id="${album['AlbumID']}" data-artist-id="${album['ArtistID']}" data-success-msg="Trying to download'${album['AlbumTitle']}'" title="Search if available for download">search</a>] [<a href="javascript:void(0)" onclick="doAjaxCall('unqueueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}',$(this),'table')" data-success="'${album['AlbumTitle']}' skipped">skip</a>] [<a href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}', $(this),'table')" data-success="Trying to download'${album['AlbumTitle']}'" title="Search if available for download">search</a>]
%else: %else:
[<a href="#" class="album-action-inline" data-action="queueAlbum" data-album-id="${album['AlbumID']}" data-artist-id="${album['ArtistID']}" data-success-msg="Retrying the same version of '${album['AlbumTitle']}'" title="Retry the same download again">retry</a>][<a href="#" class="album-action-inline" data-action="queueAlbum" data-album-id="${album['AlbumID']}" data-artist-id="${album['ArtistID']}" data-new="True" title="Try a new download, skipping all previously tried nzbs" data-success-msg="Looking for a new version of '${album['AlbumTitle']}'">new</a>] [<a href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}', $(this),'table')" data-success="Retrying the same version of '${album['AlbumTitle']}'" title="Retry the same download again">retry</a>][<a href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}&new=True', $(this),'table')" title="Try a new download, skipping all previously tried nzbs" data-success="Downloading new version for '${album['AlbumTitle']}'" data-success="Looking for a new version of '${album['AlbumTitle']}'">new</a>]
%endif %endif
%if albumformat in lossy_formats and album['Status'] == 'Skipped': %if albumformat in lossy_formats and album['Status'] == 'Skipped':
[<a id="wantlossless" href="#" class="album-action-inline" data-action="queueAlbum" data-album-id="${album['AlbumID']}" data-artist-id="${album['ArtistID']}" data-lossless="True" data-success-msg="Lossless version of '${album['AlbumTitle']}' added to queue">want lossless</a>] [<a id="wantlossless" href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}&lossless=True', $(this),'table')" data-success="Lossless version of '${album['AlbumTitle']}' added to queue">want lossless</a>]
%elif albumformat in lossy_formats and (album['Status'] == 'Snatched' or album['Status'] == 'Downloaded'): %elif albumformat in lossy_formats and (album['Status'] == 'Snatched' or album['Status'] == 'Downloaded'):
[<a id="wantlossless" href="#" class="album-action-inline" data-action="queueAlbum" data-album-id="${album['AlbumID']}" data-artist-id="${album['ArtistID']}" data-lossless="True" data-success-msg="Retrying the same lossless version of '${album['AlbumTitle']}'">retry lossless</a>] [<a id="wantlossless" href="javascript:void(0)" onclick="doAjaxCall('queueAlbum?AlbumID=${album['AlbumID']}&ArtistID=${album['ArtistID']}&lossless=True', $(this),'table')" data-success="Retrying the same lossless version of '${album['AlbumTitle']}'">retry lossless</a>]
%endif %endif
</td> </td>
<td id="have"><span title="${percent}"><span><div class="progress-container"><div style="width:${percent}%"><div class="havetracks">${havetracks}/${totaltracks}</div></div></div></td> <td id="have"><span title="${percent}"><span><div class="progress-container"><div style="width:${percent}%"><div class="havetracks">${havetracks}/${totaltracks}</div></div></div></td>
@@ -156,313 +152,177 @@
</%def> </%def>
<%def name="headIncludes()"> <%def name="headIncludes()">
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
<link rel="stylesheet" href="interfaces/default/css/data_table.css"> <link rel="stylesheet" href="interfaces/default/css/data_table.css">
</%def> </%def>
<%def name="javascriptIncludes()"> <%def name="javascriptIncludes()">
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
<script src="js/libs/jquery.dataTables.min.js"></script> <script src="js/libs/jquery.dataTables.min.js"></script>
<script> <script>
// Define a global object for page-specific functions to avoid polluting global scope directly function getArtistBio() {
var ArtistPage = ArtistPage || {};
ArtistPage.getArtistBio = function() {
var id = "${artist['ArtistID']}"; var id = "${artist['ArtistID']}";
var elem = $("#artistBio"); var elem = $("#artistBio");
// Assuming getInfo is defined in common.js getInfo(elem,id,'artist');
if (typeof getInfo === 'function') { }
getInfo(elem,id,'artist');
} else {
console.warn("getInfo function not found. Artist bio might not be loaded.");
}
};
ArtistPage.initDialogs = function() { <%
// General handler for opening dialogs based on data-dialog-id if headphones.CONFIG.SONGKICK_FILTER_ENABLED:
$(document).on('click', '.dialog-trigger', function(e) { songkick_filter_enabled = "true"
e.preventDefault(); else:
var dialogId = $(this).data('dialog-id'); songkick_filter_enabled = "false"
var $dialog = $('#' + dialogId);
if ($dialog.length) { if not headphones.CONFIG.SONGKICK_LOCATION:
$dialog.dialog({ songkick_location = "none"
width: 500, else:
maxHeight: 500, songkick_location = headphones.CONFIG.SONGKICK_LOCATION
modal: true // Added modal for better UX
});
}
});
// Submit handler for getExtrasForm if headphones.CONFIG.SONGKICK_ENABLED:
$('#getExtrasForm').on('submit', function(e) { songkick_enabled = "true"
e.preventDefault(); else:
var $this = $(this); songkick_enabled = "false"
var action = $this.attr('action');
var formData = $this.serialize();
var artistID = $this.find('input[name="ArtistID"]').val(); // Get ArtistID
var successMsg = "Extras fetch initiated for " + "${artist['ArtistName']}"; // Dynamic success message
if (typeof doAjaxCall === 'function') {
doAjaxCall(action + '?' + formData, $this, true, successMsg);
} else {
console.error("doAjaxCall function is not defined!");
}
$('#dialog').dialog("close");
});
};
ArtistPage.getArtistsCalendar = function() { %>
function getArtistsCalendar() {
var template, calendarDomNode; var template, calendarDomNode;
calendarDomNode = $("#artistCalendar"); calendarDomNode = $("#artistCalendar");
template = '<li><a target="_blank" href="URI"><span class="sk-name">NAME</span><span class="sk-location">LOC</span></a></li>'; template = '<li><a target="_blank" href="URI"><span class="sk-name">NAME</span><span class="sk-location">LOC</span></a></li>';
// Python variables are injected directly into this JS block $.getJSON("https://api.songkick.com/api/3.0/artists/mbid:${artist['ArtistID']}/calendar.json?apikey=${headphones.CONFIG.SONGKICK_APIKEY}&jsoncallback=?",
var songkick_filter_enabled = ${'true' if headphones.CONFIG.SONGKICK_FILTER_ENABLED else 'false'};
var songkick_location = ${'"{}"'.format(headphones.CONFIG.SONGKICK_LOCATION) if headphones.CONFIG.SONGKICK_LOCATION else 'null'};
var songkick_enabled = ${'true' if headphones.CONFIG.SONGKICK_ENABLED else 'false'};
var songkick_apikey = "${headphones.CONFIG.SONGKICK_APIKEY}";
if (!songkick_enabled || !songkick_apikey) {
console.log("Songkick is not enabled or API key is missing.");
return;
}
$.getJSON("https://api.songkick.com/api/3.0/artists/mbid:${artist['ArtistID']}/calendar.json?apikey=" + songkick_apikey + "&jsoncallback=?",
function(data){ function(data){
if (data['resultsPage'] && data['resultsPage'].totalEntries >= 1 && data['resultsPage'].results && data['resultsPage'].results.event) { if (data['resultsPage'].totalEntries >= 1) {
var events = data.resultsPage.results.event; if (${songkick_filter_enabled}) {
if (songkick_filter_enabled && songkick_location) { data.resultsPage.results.event = $.grep(data.resultsPage.results.event, function(element,index){
events = $.grep(events, function(element,index){ return element.venue.metroArea.id == ${songkick_location};
return element.venue && element.venue.metroArea && element.venue.metroArea.id == songkick_location;
}); });
} }
if (events.length > 0) { if (data.resultsPage.results.event.length > 0) {
var tourDate;
calendarDomNode.show(); calendarDomNode.show();
$("#artistImg").addClass('on-tour'); $("#artistImg").addClass('on-tour');
$.each(events, function(i, event) { jQuery.each(data.resultsPage.results.event, function(i, event) {
var tourDate = template; tourDate = template;
tourDate = tourDate.replace('URI', event.uri || '#'); tourDate = tourDate.replace('URI',event.uri);
tourDate = tourDate.replace('NAME', event.displayName || 'N/A'); tourDate = tourDate.replace('NAME',event.displayName);
tourDate = tourDate.replace('LOC', (event.location && event.location.city) ? event.location.city : 'N/A'); tourDate = tourDate.replace('LOC',event.location.city);
calendarDomNode.append(tourDate); calendarDomNode.append(tourDate);
}); });
calendarDomNode.append('<li><img src="interfaces/default/images/songkick.png" alt="concerts by songkick" class="sk-logo" /></li>'); calendarDomNode.append('<li><img src="interfaces/default/images/songkick.png" alt="concerts by songkick" class="sk-logo" /></li>');
// Handle "More..." button logic for calendar $(function() {
calendarDomNode.each(function() { $("#artistCalendar").each(function() {
$("li:gt(4)", this).hide(); /* :gt() is zero-indexed */ $("li:gt(4)", this).hide(); /* :gt() is zero-indexed */
$("li:nth-child(5)", this).after("<br><li class='more'><a href='#'>More...</a></li>"); /* :nth-child() is one-indexed */ $("li:nth-child(5)", this).after("<br><li class='more'><a href='#'>More...</a></li>"); /* :nth-child() is one-indexed */
}); });
$("li.more").on("click", 'a', function(e) { $("li.more").on("click", 'a', function() {
e.preventDefault(); // Prevent default link behavior
var li = $(this).parents("li:first"); var li = $(this).parents("li:first");
li.parent().children().show(); li.parent().children().show();
li.remove(); li.remove();
return false;
});
}); });
} }
} }
}).fail(function(jqXHR, textStatus, errorThrown) { }
console.error("Songkick API call failed: ", textStatus, errorThrown); );
}); }
};
ArtistPage.loadingMessage = false; var loadingMessage = false;
ArtistPage.spinner_active = false; var spinner_active = false;
ArtistPage.loadingtext_active = false; var loadingtext_active = false;
ArtistPage.refreshInterval = null; // Initialize as null
ArtistPage.checkArtistStatus = function() { function checkArtistStatus() {
$.getJSON("getArtistjson?ArtistID=${artist['ArtistID']}", function(data) { $.getJSON("getArtistjson?ArtistID=${artist['ArtistID']}", function(data) {
if (data['Status'] === "Loading"){ if (data['Status'] == "Loading"){
// Assuming refreshTable() is defined globally or in common.js and it updates the album_table refreshTable();
if (typeof refreshTable === 'function') {
refreshTable(); // Refresh the album table to show loading states if implemented there
}
$('#artistnamelink').text(data["ArtistName"]); $('#artistnamelink').text(data["ArtistName"]);
if (ArtistPage.loadingMessage === false){ if (loadingMessage == false){
$("#ajaxMsg").after( "<div id='ajaxMsg2' class='ajaxMsg'></div>" ); $("#ajaxMsg").after( "<div id='ajaxMsg2' class='ajaxMsg'></div>" );
if (typeof showArtistMsg === 'function') { showArtistMsg("Getting artist information");
showArtistMsg("Getting artist information"); loadingMessage = true;
}
ArtistPage.loadingMessage = true;
} }
if (ArtistPage.spinner_active === false){ if (spinner_active == false){
$('#artistname').prepend('<i class="fa fa-refresh fa-spin" id="artistnamespinner"></i>'); $('#artistname').prepend('<i class="fa fa-refresh fa-spin" id="artistnamespinner"></i>')
ArtistPage.spinner_active = true; spinner_active = true;
} }
if (ArtistPage.loadingtext_active === false){ if (loadingtext_active == false){
$('#artistname').append('<h3 id="loadingtext"><i>(Album information for this artist is currently being loaded)</i></h3>'); $('#artistname').append('<h3 id="loadingtext"><i>(Album information for this artist is currently being loaded)</i></h3>')
ArtistPage.loadingtext_active = true; loadingtext_active = true;
} }
} else {
// Only reload if previously loading to avoid unnecessary refreshes
if (ArtistPage.spinner_active || ArtistPage.loadingtext_active || ArtistPage.loadingMessage) {
location.reload(); // Reload the page to show updated status once loading is complete
}
$('#artistnamespinner').remove();
$('#loadingtext').remove();
$('#ajaxMsg2').remove();
ArtistPage.spinner_active = false;
ArtistPage.loadingtext_active = false;
ArtistPage.loadingMessage = false;
} }
}).fail(function(jqXHR, textStatus, errorThrown) { else{
console.error("Error checking artist status: ", textStatus, errorThrown); $('#artistnamespinner').remove()
}); $('#loadingtext').remove()
}; $('#ajaxMsg2').remove()
spinner_active = false
loadingtext_active = false
loadingMessage = false
}
});
}
ArtistPage.initDataTables = function() { function initThisPage() {
$('#album_table').DataTable({ $('#menu_link_getextra').click(function(event) {
"destroy": true, // bDestroy -> destroy $('#dialog').dialog();
"columns": [ // aoColumns -> columns event.preventDefault();
null, null, null,
{ "type": "date" }, // sType -> type
null, null, null,
{ "type": "title-numeric"},
null, null
],
"columnDefs": [ // aoColumnDefs -> columnDefs
{ 'orderable': false, 'targets': [ 0, 1 ] } // bSortable -> orderable, aTargets -> targets
],
"stateSave": true, // bStateSave -> stateSave
"language": { // oLanguage -> language
"lengthMenu":"Show _MENU_ albums per page",
"emptyTable": "No album information available",
"info":"Showing _TOTAL_ albums",
"infoEmpty":"Showing 0 to 0 of 0 albums",
"infoFiltered":"(filtered from _MAX_ total albums)",
"search": "" // No need for "Search: " label if using custom icon
},
"paging": false, // bPaginate -> paging
"order": [[4, 'asc'],[3,'desc']] // aaSorting -> order
}); });
// Assuming resetFilters is defined globally or in common.js $('#menu_link_modifyextra').click(function(event) {
if (typeof resetFilters === 'function') { $('#dialog').dialog();
resetFilters("albums"); event.preventDefault();
} });
}; $('#album_table').dataTable({
"bDestroy": true,
"aoColumns": [
null,
null,
null,
{ "sType": "date" },
null,
null,
null,
{ "sType": "title-numeric"},
null,
null
],
"aoColumnDefs": [
{ 'bSortable': false, 'aTargets': [ 0,1 ] }
],
"bStateSave": true,
"oLanguage": {
"sLengthMenu":"Show _MENU_ albums per page",
"sEmptyTable": "No album information available",
"sInfo":"Showing _TOTAL_ albums",
"sInfoEmpty":"Showing 0 to 0 of 0 albums",
"sInfoFiltered":"(filtered from _MAX_ total albums)",
"sSearch": ""},
"bPaginate": false,
"aaSorting": [[4, 'asc'],[3,'desc']]
});
resetFilters("albums");
setTimeout(function(){
initFancybox();
},1500);
}
$(document).ready(function() { $(document).ready(function() {
// Init common actions if they are not already handled in base.html document.ready initActions();
// if (typeof initActions === 'function') { initThisPage();
// initActions(); getArtistBio();
// } if( ${songkick_enabled} ){
getArtistsCalendar();
ArtistPage.getArtistBio();
ArtistPage.initDialogs();
ArtistPage.initDataTables();
// Songkick Calendar
// The python config values are injected above, so they are available here.
if( ${'true' if headphones.CONFIG.SONGKICK_ENABLED else 'false'} ){
ArtistPage.getArtistsCalendar();
} }
checkArtistStatus();
// Artist Status Polling setInterval(function(){
ArtistPage.checkArtistStatus(); // Initial check checkArtistStatus();
ArtistPage.refreshInterval = setInterval(function(){ }, 1500);
ArtistPage.checkArtistStatus();
}, 3000); // Increased interval to 3 seconds, was 1.5s which is very frequent
// Event handler for artist actions in subhead_menu
$('#subhead_menu').on('click', '.artist-action', function(e) {
e.preventDefault();
var $this = $(this);
var action = $this.data('action');
var artistId = $this.data('artist-id');
var artistName = $this.data('artist-name'); // For removeExtras
var successMsg = $this.data('success-msg');
var url = action + '?ArtistID=' + artistId;
if (artistName) url += '&ArtistName=' + encodeURIComponent(artistName);
if (typeof doAjaxCall === 'function') {
doAjaxCall(url, $this, true, successMsg); // 'true' for refreshing content, if common.js supports it
} else if (typeof doSimpleAjaxCall === 'function' && action === 'refreshArtist') {
// Fallback for refreshArtist if doAjaxCall isn't what's expected for it
doSimpleAjaxCall(url);
} else {
console.error("doAjaxCall or doSimpleAjaxCall function is not defined!");
}
});
// Event handler for album actions within the table
// Delegated to #album_table body as these links are dynamically added
$('#album_table tbody').on('click', '.album-action-inline', function(e) {
e.preventDefault();
var $this = $(this);
var action = $this.data('action');
var albumId = $this.data('album-id');
var artistId = $this.data('artist-id');
var newStatus = $this.data('new'); // Boolean 'true' or 'false'
var lossless = $this.data('lossless'); // Boolean 'true' or 'false'
var successMsg = $this.data('success-msg');
var url = action + '?AlbumID=' + albumId + '&ArtistID=' + artistId;
if (newStatus !== undefined) url += '&new=' + newStatus;
if (lossless !== undefined) url += '&lossless=' + lossless;
if (typeof doAjaxCall === 'function') {
doAjaxCall(url, $this, 'table', successMsg); // 'table' for refreshing content, if common.js supports it
} else {
console.error("doAjaxCall function is not defined!");
}
});
// "Mark selected albums as" dropdown change
$('#markAlbumsActionSelect').on('change', function() {
var selectedAction = $(this).val();
if (selectedAction) {
// Manually trigger form submission with AJAX
var $form = $('#markAlbumsForm');
var formData = $form.serialize(); // Includes ArtistID and action
// Get selected album IDs
var selectedAlbumIDs = $('.album-checkbox:checked').map(function() {
return $(this).attr('name'); // AlbumID is the name attribute
}).get();
if (selectedAlbumIDs.length === 0) {
// Show error message as per original data-error
if (typeof doAjaxCall === 'function') { // Assuming doAjaxCall can show errors
doAjaxCall(null, $(this), null, null, 'You didn\'t select any albums');
} else {
alert('You didn\'t select any albums');
}
$(this).val('Choose...'); // Reset dropdown
return;
}
// Construct the URL for markAlbums action
var url = $form.attr('action') + '?' + formData;
// Add selected album IDs as a comma-separated list or multiple parameters
$.each(selectedAlbumIDs, function(index, id) {
url += '&AlbumID=' + id; // Append each selected AlbumID
});
// Assuming doAjaxCall is defined in common.js
if (typeof doAjaxCall === 'function') {
// Using null for the element as it's a general form submission
doAjaxCall(url, null, 'table', 'Albums marked successfully'); // 'table' for refreshing, success message
} else {
console.error("doAjaxCall function is not defined!");
}
$(this).val('Choose...'); // Reset dropdown after action
}
});
// "Select all" checkbox functionality
$('.select-all-checkbox').on('change', function() {
$('.album-checkbox').prop('checked', $(this).prop('checked'));
});
}); });
</script> </script>
</%def> </%def>
+119 -159
View File
@@ -2,182 +2,142 @@
import headphones import headphones
from headphones import version from headphones import version
%> %>
<!DOCTYPE html> <!doctype html>
<html lang="en"> <!--[if IE 7 ]> <html lang="en" class="no-js ie7"> <![endif]-->
<!--[if IE 8 ]> <html lang="en" class="no-js ie8"> <![endif]-->
<!--[if IE 9 ]> <html lang="en" class="no-js ie9"> <![endif]-->
<!--[if (gt IE 9)|!(IE)]><!--> <html lang="en" class="no-js"> <!--<![endif]-->
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Headphones - ${title}</title> <title>Headphones - ${title}</title>
<meta name="description" content="Headphones 'default' interface - made by Elmar Kouwenhoven"> <meta name="description" content="Headphones 'default' interface - made by Elmar Kouwenhoven">
<meta name="author" content="Elmar Kouwenhoven"> <meta name="author" content="Elmar Kouwenhoven">
<link rel="icon" href="images/favicon.ico"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="apple-touch-icon" href="images/headphoneslogo.png">
<link rel="stylesheet" href="css/jquery-ui.min.css"> <link rel="shortcut icon" href="images/favicon.ico">
<link rel="stylesheet" href="interfaces/default/css/style.css"> <link rel="apple-touch-icon" href="images/headphoneslogo.png">
<link rel="stylesheet" href="interfaces/default/css/font-awesome.min.css">
${next.headIncludes()} <link rel="stylesheet" href="css/jquery-ui.min.css">
<link rel="stylesheet" href="interfaces/default/css/style.css">
<link rel="stylesheet" href="interfaces/default/css/font-awesome.min.css">
${next.headIncludes()}
<script src="js/libs/modernizr-2.8.3.min.js"></script>
</head> </head>
<body> <body>
<div id="container"> <div id="container">
<div id="ajaxMsg" class="ajaxMsg"></div> <div id="ajaxMsg" class="ajaxMsg"></div>
% if headphones.CONFIG.CHECK_GITHUB and not headphones.CURRENT_VERSION:
<div id="updatebar">
You're running an unknown version of Headphones. <a href="update">Update</a> or
<a href="javascript:void(0)" onclick="$('#updatebar').slideUp('slow');">Close</a>
</div>
% elif headphones.CONFIG.CHECK_GITHUB and headphones.CURRENT_VERSION != headphones.LATEST_VERSION and headphones.COMMITS_BEHIND > 0 and headphones.INSTALL_TYPE != 'win':
<div id="updatebar">
A <a href="https://github.com/${headphones.CONFIG.GIT_USER}/headphones/compare/${headphones.CURRENT_VERSION}...${headphones.LATEST_VERSION}"> newer version</a> is available. You're ${headphones.COMMITS_BEHIND} commits behind. <a href="update">Update</a> or <a href="javascript:void(0)" onclick="$('#updatebar').slideUp('slow');">Close</a>
</div>
% endif
% if headphones.CONFIG.CHECK_GITHUB and not headphones.CURRENT_VERSION: <header>
<div id="updatebar"> <div class="wrapper">
You're running an unknown version of Headphones. <a href="update">Update</a> or <div id="logo">
<a href="#" class="close-updatebar">Close</a> <a href="home"><img src="images/headphoneslogo.png" alt="headphones" width="64"></a>
</div> </div>
% elif headphones.CONFIG.CHECK_GITHUB and headphones.CURRENT_VERSION != headphones.LATEST_VERSION and headphones.COMMITS_BEHIND > 0 and headphones.INSTALL_TYPE != 'win': <ul id="nav">
<div id="updatebar"> <li><a href="upcoming">wanted</a></li>
A <a href="https://github.com/${headphones.CONFIG.GIT_USER}/headphones/compare/${headphones.CURRENT_VERSION}...${headphones.LATEST_VERSION}"> newer version</a> is available. You're ${headphones.COMMITS_BEHIND} commits behind. <a href="update">Update</a> or <a href="#" class="close-updatebar">Close</a> <li><a href="extras">extras</a></li>
</div> <li><a href="manage">manage</a></li>
% endif <li><a href="history">history</a></li>
<li><a href="logs">logs</a></li>
<li><a href="config" class="config"><i class="fa fa-gear fa-lg"></i></a></li>
</ul>
<div id="searchbar">
<form action="search" method="get">
<input type="text" value="" placeholder="Search" onfocus="if(this.value==this.defaultValue) this.value='';" name="name" />
<i class='fa fa-search mini-icon'></i>
<select name="type" id="search_type">
<option value="artist">Artist</option>
<option value="album">Album</option>
<option value="series">Series</option>
</select>
<input type="submit" value="Search"/>
</form>
</div>
<header> </div>
<div class="wrapper"> </header>
<div id="logo">
<a href="home"><img src="images/headphoneslogo.png" alt="headphones" width="64"></a>
</div>
<nav id="nav">
<ul>
<li><a href="upcoming">wanted</a></li>
<li><a href="extras">extras</a></li>
<li><a href="manage">manage</a></li>
<li><a href="history">history</a></li>
<li><a href="logs">logs</a></li>
<li><a href="config" class="config" aria-label="Configuration"><i class="fa fa-gear fa-lg"></i></a></li>
</ul>
</nav>
<div id="searchbar">
<form action="search" method="get">
<input type="text" value="" placeholder="Search" name="name" id="search-input" />
<i class='fa fa-search mini-icon'></i>
<select name="type" id="search_type">
<option value="artist">Artist</option>
<option value="album">Album</option>
<option value="series">Series</option>
</select>
<button type="submit">Search</button>
</form>
</div>
</div>
</header>
<main id="main" class="main"> <div id="main" class="main">
<div id="subhead"> <div id="subhead">
${next.headerIncludes()} ${next.headerIncludes()}
</div> </div>
${next.body()} ${next.body()}
</main> </div>
<footer> <footer>
<div id="info"> <div id="info">
<small> <small>
<a href="https://github.com/rembo10/headphones"><i class="fa fa-headphones"></i> Website</a> | <a href="https://github.com/rembo10/headphones"><i class="fa fa-headphones"></i> Website</a> |
%if headphones.CONFIG.GIT_USER != 'rembo10': %if headphones.CONFIG.GIT_USER != 'rembo10':
<a href="https://github.com/${headphones.CONFIG.GIT_USER}/headphones" title="Open this fork on github"><i class="fa fa-github"></i> GitHub</a> | <a href="https://github.com/${headphones.CONFIG.GIT_USER}/headphones" title="Open this fork on github"><i class="fa fa-github"></i> GitHub</a> |
%endif %endif
<a href="https://github.com/rembo10/headphones/wiki/TroubleShooting"><i class="fa fa-ambulance"></i> Help</a> <a href="https://github.com/rembo10/headphones/wiki/TroubleShooting"><i class="fa fa-ambulance"></i> Help</a>
</small> </small>
</div> </div>
<div id="actions"> <div id="actions">
<small> <small>
<a href="shutdown"><i class="fa fa-power-off"></i> Shutdown</a> | <a href="shutdown"><i class="fa fa-power-off"></i> Shutdown</a> |
<a href="restart"><i class="fa fa-power-off"></i> Restart</a> | <a href="restart"><i class="fa fa-power-off"></i> Restart</a> |
<a href="#" class="check-update-btn" data-success="Checking for update successful" data-error="Error checking for update"><i class="fa fa-refresh"></i> Check for new version</a> <a href="javascript:void(0)" onclick="doAjaxCall('checkGithub',$(this))" data-success="Checking for update successful" data-error="Error checking for update"><i class="fa fa-refresh"></i> Check for new version</a>
</small> </small>
</div> </div>
<div id="version"> <div id="version">
Version: <em>${headphones.CURRENT_VERSION}</em> Version: <em>${headphones.CURRENT_VERSION}</em>
%if version.HEADPHONES_VERSION != 'master': %if version.HEADPHONES_VERSION != 'master':
(${version.HEADPHONES_VERSION}) (${version.HEADPHONES_VERSION})
%endif %endif
%if headphones.CONFIG.GIT_BRANCH != 'master': %if headphones.CONFIG.GIT_BRANCH != 'master':
(${headphones.CONFIG.GIT_BRANCH}) (${headphones.CONFIG.GIT_BRANCH})
%endif %endif
</div> </div>
</footer> </footer>
<a href="#main" id="toTop" aria-label="Back to top"><i class="fa fa-angle-double-up"></i> <span>Back to top</span></a> <a href="#main" id="toTop"><i class="fa fa-angle-double-up"></i> <span>Back to top</span></a>
</div> </div>
<script src="js/libs/jquery-3.7.1.min.js"></script> <script src="js/libs/jquery-1.11.1.min.js"></script>
<script src="js/libs/jquery-ui.min.js"></script> <script src="js/libs/jquery-ui.min.js"></script>
<script src="js/common.js"></script> <script src="js/common.js"></script>
${next.javascriptIncludes()} ${next.javascriptIncludes()}
<script src="interfaces/default/js/script.js"></script> <script src="interfaces/default/js/script.js"></script>
<!-- This template is made by Elmar Kouwenhoven -->
<script type="text/javascript"> <!-- Persist search type using local storage -->
$(document).ready(function() { <script type="text/javascript">
// Focus on the first form input that's not hidden
$('form:first *:input[type!=hidden]:first').focus();
// Persist search type using local storage $(document).ready(function() {
try { $('form:first *:input[type!=hidden]:first').focus();
var type = window.localStorage.getItem('search_type') || "artist"; try{
$("#search_type").val(type); var type = window.localStorage.getItem('search_type') || "artist";
} catch (e) { $("#search_type").val(type);
console.error("Local Storage not available or error accessing it:", e); } catch(e) {
} }
});
// Modernized event listener for closing update bar (delegated) $('select[id=search_type]').change(function() {
// Uses event delegation for robustness: attaches handler to parent (#container) var type = $(this).val()
// which listens for clicks on elements with class .close-updatebar try{
$('#container').on('click', '.close-updatebar', function(e) { window.localStorage.setItem('search_type', type);
e.preventDefault(); // Prevent default link behavior } catch(e) {
$(this).closest('#updatebar').slideUp('slow'); }
}); });
// Modernized event listener for "Check for new version" (delegated) </script>
$('#actions').on('click', '.check-update-btn', function(e) {
e.preventDefault(); // Prevent default link behavior
// Assuming doAjaxCall is defined in common.js or script.js
doAjaxCall('checkGithub', $(this));
});
// Event listener for search type change
$('select[id=search_type]').on('change', function() {
var type = $(this).val();
try {
window.localStorage.setItem('search_type', type);
} catch (e) {
console.error("Local Storage not available or error accessing it:", e);
}
});
// Handle placeholder text for search input (no longer needs onfocus in HTML)
$('#search-input').on('focus', function() {
if (this.value === this.defaultValue) {
this.value = '';
}
}).on('blur', function() {
if (this.value === '') {
this.value = this.defaultValue;
}
});
// "Back to top" functionality
// Show/hide #toTop button based on scroll position
$(window).scroll(function() {
if ($(this).scrollTop() > 100) { // Show after scrolling 100px
$('#toTop').fadeIn();
} else {
$('#toTop').fadeOut();
}
});
// Smooth scroll to top when button is clicked
$('#toTop').click(function(e) {
e.preventDefault();
$('html, body').animate({scrollTop : 0}, 800);
return false;
});
});
</script>
</body> </body>
</html> </html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -47
View File
@@ -1,5 +1,4 @@
<%inherit file="base.html" /> <%inherit file="base.html" />
<%def name="body()"> <%def name="body()">
<div class="title"> <div class="title">
<h1 class="clearfix"><i class="fa fa-users"></i> Artists You Might Like</h1> <h1 class="clearfix"><i class="fa fa-users"></i> Artists You Might Like</h1>
@@ -8,54 +7,9 @@
<div class="cloudtag"> <div class="cloudtag">
<ul id="cloud"> <ul id="cloud">
%for artist in cloudlist: %for artist in cloudlist:
<%-- <li><a href="addArtist?artistid=${artist['ArtistID']}" class="tag${artist['Count']}">${artist['ArtistName']}</a></li>
Modified the anchor tag to use data attributes for AJAX
and href="#" to prevent default navigation.
--%>
<li>
<a href="#" class="tag${artist['Count']} add-artist-link"
data-artist-id="${artist['ArtistID']}"
data-artist-name="${artist['ArtistName']}">
${artist['ArtistName']}
</a>
</li>
%endfor %endfor
</ul> </ul>
</div> </div>
</div> </div>
</%def> </%def>
<%def name="javascriptIncludes()">
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
<script>
$(document).ready(function() {
// Delegated event handler for 'add-artist-link' class
$('#cloud').on('click', '.add-artist-link', function(e) {
e.preventDefault(); // Prevent the default link behavior (page navigation)
var $this = $(this);
var artistId = $this.data('artist-id');
var artistName = $this.data('artist-name'); // Get artist name for feedback
// Assuming 'doAjaxCall' is available globally (from common.js or similar)
// and handles displaying success/error messages.
if (typeof doAjaxCall === 'function') {
doAjaxCall('addArtist?artistid=' + artistId, $this, 'simple',
'Artist "' + artistName + '" added successfully!');
// Optionally, fade out or remove the artist from the list after adding
$this.closest('li').fadeOut(500, function() {
$(this).remove();
});
} else {
console.error("doAjaxCall function is not defined. Cannot add artist asynchronously.");
// Fallback: allow the default link behavior if AJAX is not possible
// window.location.href = 'addArtist?artistid=' + artistId;
// Or just log an error and do nothing if page reload is not desired.
}
});
});
</script>
</%def>
-2
View File
@@ -56,8 +56,6 @@
fileid = 'torrent' fileid = 'torrent'
if item['URL'].find('codeshy') != -1: if item['URL'].find('codeshy') != -1:
fileid = 'nzb' fileid = 'nzb'
if item['URL'].find('bandcamp') != -1:
fileid = 'bandcamp'
folder = 'Folder: ' + item['FolderName'] folder = 'Folder: ' + item['FolderName']
+63 -131
View File
@@ -7,11 +7,11 @@
<table class="display" id="artist_table"> <table class="display" id="artist_table">
<thead> <thead>
<tr> <tr>
<th class="column-albumart"></th> <%-- Changed id to class --%> <th id="albumart"></th>
<th class="column-name">Artist Name</th> <th id="name">Artist Name</th>
<th class="column-status">Status</th> <th id="status">Status</th>
<th class="column-album">Latest Release</th> <th id="album">Latest Release</th>
<th class="column-have">Have</th> <th id="have">Have</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -20,52 +20,14 @@
</%def> </%def>
<%def name="headIncludes()"> <%def name="headIncludes()">
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
<link rel="stylesheet" href="interfaces/default/css/data_table.css"> <link rel="stylesheet" href="interfaces/default/css/data_table.css">
<style>
/* Basic CSS for album art thumbnail */
.albumArt-thumb {
height: 50px;
width: 50px;
object-fit: cover; /* Ensures image covers the area without distortion */
vertical-align: middle;
}
/* Styling for the progress bar */
.progress-container {
background-color: #eee;
border-radius: 5px;
height: 15px; /* Adjust height as needed */
overflow: hidden;
position: relative;
width: 100%; /* Or a fixed width if preferred */
}
.progress-container > div {
background-color: #4CAF50; /* Green color for progress */
height: 100%;
border-radius: 5px;
text-align: center;
color: white;
font-size: 10px; /* Smaller font for percentage */
line-height: 15px; /* Vertically align text */
}
.havetracks {
padding: 0 5px; /* Add some padding around the text */
}
</style>
</%def> </%def>
<%def name="javascriptIncludes()"> <%def name="javascriptIncludes()">
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%> <script src="js/libs/jquery.unveil.min.js"></script>
<script src="js/libs/jquery.unveil.min.js"></script> <%-- Keep if still using for lazy loading --%>
<script src="js/libs/jquery.dataTables.min.js"></script> <script src="js/libs/jquery.dataTables.min.js"></script>
<script> <script>
// Encapsulate page-specific logic function initThisPage() {
var IndexPage = IndexPage || {};
IndexPage.initDataTable = function() {
$('#artist_table').dataTable({ $('#artist_table').dataTable({
"bDestroy": true, "bDestroy": true,
"aoColumnDefs": [ "aoColumnDefs": [
@@ -74,17 +36,14 @@
"aTargets": [0], "aTargets": [0],
"mData":"ArtistID", "mData":"ArtistID",
"mRender": function ( data, type, full ) { "mRender": function ( data, type, full ) {
// Using a class for the image, and data-src for lazy loading return '<div id="artistImg"><img class="albumArt" height="50" width="50" alt="" id="'+ data + '" data-src="artwork/thumbs/artist/' + data + '"/></div>';
// Added loading="lazy" for native lazy loading support where available
return '<div class="artistImg-container"><img class="albumArt-thumb" alt="Album art for ' + full['ArtistName'] + '" data-src="artwork/thumbs/artist/' + data + '" loading="lazy" /></div>';
} }
}, },
{ {
"aTargets":[1], "aTargets":[1],
"mDataProp":"ArtistSortName", "mDataProp":"ArtistSortName",
"mRender":function (data,type,full) { "mRender":function (data,type,full) {
// Using a class for the span, not an ID return '<span title="' + full['ArtistID'] + '"></span><a href="artistPage?ArtistID=' + full['ArtistID'] + '">' + full['ArtistName'] + '</a>'
return '<span class="artist-sort-name" title="' + full['ArtistID'] + '"></span><a href="artistPage?ArtistID=' + full['ArtistID'] + '">' + full['ArtistName'] + '</a>'
} }
}, },
{ {
@@ -94,52 +53,52 @@
"aTargets":[3], "aTargets":[3],
"mDataProp":"LatestAlbum", "mDataProp":"LatestAlbum",
"mRender":function(data,type,full){ "mRender":function(data,type,full){
var artist = full; // Renamed to avoid confusion with outer scope artist = full;
var releasedate = ''; if (artist['ReleaseDate'] && artist['LatestAlbum'])
var albumdisplay = '<i>None</i>'; {
var grade = 'gradeZ'; // Default grade
if (artist['ReleaseDate'] && artist['LatestAlbum']) {
releasedate = artist['ReleaseDate']; releasedate = artist['ReleaseDate'];
albumdisplay = '<i>' + artist['LatestAlbum'] + '</i> (' + artist['ReleaseDate'] + ')'; albumdisplay = '<i>' + artist['LatestAlbum'] + '</i> (' + artist['ReleaseDate'] + ')';
} else if (artist['LatestAlbum']) { }
else if(artist['LatestAlbum'])
{
releasedate = '';
albumdisplay = '<i>' + artist['LatestAlbum'] + '</i>'; albumdisplay = '<i>' + artist['LatestAlbum'] + '</i>';
} }
else
if (artist['ReleaseInFuture'] === 'True') { {
releasedate = '';
albumdisplay = '<i>None</i>';
}
if (artist['ReleaseInFuture'] === 'True')
{
grade = 'gradeA'; grade = 'gradeA';
} }
// artist['Grade'] is used in fnRowCallback, ensure it's set in the data. else
// If this 'Grade' is only for client-side sorting/filtering, it's fine. {
// If it affects server-side logic, it should be handled there. grade = 'gradeZ';
full['Grade'] = grade; // Ensure grade is part of the row data for fnRowCallback }
artist['Grade'] = grade;
// Using a class for the span, not an ID return '<span title="' + releasedate + '"></span><a href="albumPage?AlbumID=' + full['AlbumID'] + '">' + albumdisplay + '</a>'
return '<span class="release-date-sort" title="' + releasedate + '"></span><a href="albumPage?AlbumID=' + full['AlbumID'] + '">' + albumdisplay + '</a>'
} }
}, },
{ {
"aTargets":[4], "aTargets":[4],
"mDataProp":"HaveTracks", "mDataProp":"HaveTracks",
"mRender":function(data,type,full){ "mRender":function(data,type,full){
var percent = 0; if(full['TotalTracks'] > 0)
var totalTracksDisplay = '?'; {
percent = (full['HaveTracks']*100.0)/full['TotalTracks']
if (full['TotalTracks'] > 0) { if(percent > 100){
percent = (full['HaveTracks']*100.0)/full['TotalTracks'];
if (percent > 100) {
percent = 100; percent = 100;
} }
totalTracksDisplay = full['TotalTracks']; }
else
{
full['TotalTracks'] = '?';
percent = 0;
} }
// Added ARIA attributes for accessibility return '<span title="' + percent + '"></span><div class="progress-container"><div style="width:' + percent + '%"><div class="havetracks">' + full['HaveTracks'] + '/' + full['TotalTracks'] + '</div></div></div>';
return '<span class="have-tracks-sort" title="' + percent + '"></span>' +
'<div class="progress-container" role="progressbar" aria-valuenow="' + Math.round(percent) + '" aria-valuemin="0" aria-valuemax="100">' +
'<div style="width:' + percent + '%">' +
'<div class="havetracks">' + full['HaveTracks'] + '/' + totalTracksDisplay + '</div>' +
'</div>' +
'</div>';
} }
}, },
], ],
@@ -151,73 +110,46 @@
"sInfoFiltered":"(filtered from _MAX_ total artists)", "sInfoFiltered":"(filtered from _MAX_ total artists)",
"sEmptyTable": " ", "sEmptyTable": " ",
}, },
"bStateSave": true, // Retain table state across page loads "bStateSave": true,
"iDisplayLength": 50, "iDisplayLength": 50,
"sPaginationType": "full_numbers", "sPaginationType": "full_numbers",
"bProcessing": true, // Show processing indicator "bProcessing": true,
"bServerSide": true, // Enable server-side processing "bServerSide": true,
"sAjaxSource": 'getArtists.json', // API endpoint for data "sAjaxSource": 'getArtists.json',
"fnRowCallback": function(nRow, aData, iDisplayIndex, iDisplayIndexFull) { "fnRowCallback": function(nRow, aData, iDisplayIndex, iDisplayIndexFull) {
// Apply the 'Grade' class from aData to the table row $('td', nRow).closest('tr').addClass(aData['Grade'])
$(nRow).addClass(aData['Grade']); nRow.children[0].id = 'albumart';
nRow.children[1].id = 'name';
// Removed setting duplicate IDs on child elements. Use classes if needed for styling. nRow.children[2].id = 'status'
// For example: nRow.children[3].id = 'album'
// $(nRow).find('td:eq(0)').addClass('albumart-cell'); nRow.children[4].id = 'have'
// $(nRow).find('td:eq(1)').addClass('name-cell');
// etc.
return nRow; return nRow;
}, },
"fnServerData": function ( sSource, aoData, fnCallback ) { "fnServerData": function ( sSource, aoData, fnCallback ) {
// Custom function for fetching data, using $.getJSON /* Add some extra data to the sender */
$.getJSON( sSource, aoData, function (json) { $.getJSON( sSource, aoData, function (json) { fnCallback(json) } )
fnCallback(json); },
}).fail(function(jqXHR, textStatus, errorThrown) { "fnInitComplete": function(oSettings, json)
console.error("Error fetching artist data:", textStatus, errorThrown); {
// Provide user feedback if data loading fails
// e.g., show an error message in the table or a global notification
});
}, },
// Removed fnInitComplete as it was empty
"fnDrawCallback": function (o) { "fnDrawCallback": function (o) {
// Jump to top of page // Jump to top of page
$('html,body').scrollTop(0); $('html,body').scrollTop(0);
// Re-unveil images after each draw for lazy loading
$("img.albumArt-thumb").unveil();
} }
}); });
// jQuery Unveil listener for new images added to the DOM after DataTables draw $('#artist_table').on("draw.dt", function () {
// This is handled in fnDrawCallback now for better integration with DataTables. $("img").unveil();
// $('#artist_table').on("draw.dt", function () { });
// $("img.albumArt-thumb").unveil();
// });
// Assuming resetFilters is a global function from common.js resetFilters("artist or album");
if (typeof resetFilters === 'function') { }
resetFilters("artist or album");
} else {
console.warn("resetFilters function is not defined.");
}
};
$(document).ready(function() { $(document).ready(function() {
IndexPage.initDataTable(); initThisPage();
}); });
$(window).load(function(){
$(window).on('load', function(){ initFancybox();
// Ensure these functions exist and are necessary. refreshLoadArtist();
// If initFancybox or refreshLoadArtist are global, call them here.
// Otherwise, they should be encapsulated or refactored.
if (typeof initFancybox === 'function') {
initFancybox();
} else {
console.warn("initFancybox function is not defined.");
}
if (typeof refreshLoadArtist === 'function') {
refreshLoadArtist();
} else {
console.warn("refreshLoadArtist function is not defined.");
}
}); });
</script> </script>
</%def> </%def>
+370 -523
View File
@@ -1,539 +1,386 @@
/** function getThumb(imgElem,id,type) {
* @file headphones.js
* @brief Main JavaScript file for Headphones web interface. if ( type == 'artist' ) {
* Contains core UI, API, and utility functions. var thumbURL = "getThumb?ArtistID=" + id;
*/ // var imgURL = "getArtwork?ArtistID=" + id;
} else {
var Headphones = Headphones || {}; var thumbURL = "getThumb?AlbumID=" + id;
// var imgURL = "getArtwork?AlbumID=" + id;
// --- Configuration --- }
Headphones.config = { // Get Data from the cache by Artist ID
fallbackImage: "interfaces/default/images/no-cover-art.png", // Generic fallback $.ajax({
fallbackArtistImage: "interfaces/default/images/no-cover-artist.png", // Specific artist fallback url: thumbURL,
messageTimeout: 3000 // Default timeout for messages in milliseconds cache: true,
}; success: function(data){
if ( data == "" ) {
// --- UI Messaging Module --- var imageUrl = "interfaces/default/images/no-cover-artist.png";
Headphones.UI = Headphones.UI || {}; }
Headphones.UI.Message = (function() { else {
var $ajaxMsg = $("#ajaxMsg"); var imageUrl = data;
var $ajaxMsg2 = $("#ajaxMsg2"); // Assuming this is for artist-specific messages }
var $updateBar = $("#updatebar"); $(imgElem).attr("src",imageUrl).hide().fadeIn();
// $(imgElem).wrap('<a href="'+ imgURL +'" rel="dialog" title="' + name + '"></a>');
/** }
* Shows a feedback message to the user. });
* @param {string} msg - The message to display.
* @param {string} type - Type of message ('success', 'error', 'info', 'loading').
* @param {number} [timeout=Headphones.config.messageTimeout] - Duration to display the message (in ms). Set to 0 for no timeout.
* @param {HTMLElement} [$targetElement=$ajaxMsg] - The jQuery element to display the message in.
*/
function showMessage(msg, type, timeout, $targetElement) {
timeout = typeof timeout !== 'undefined' ? timeout : Headphones.config.messageTimeout;
$targetElement = $targetElement || $ajaxMsg;
// Adjust position if update bar is visible
if ($updateBar.is(":visible")) {
var height = $updateBar.height() + 35;
$targetElement.css("bottom", height + "px");
} else {
$targetElement.removeAttr("style");
}
$targetElement.fadeIn();
$targetElement.removeClass('success error info'); // Clear previous states
var iconClass = '';
switch (type) {
case 'success':
iconClass = 'fa-check';
$targetElement.addClass('success');
break;
case 'error':
iconClass = 'fa-exclamation-triangle';
$targetElement.addClass('error');
break;
case 'loading':
iconClass = 'fa-refresh fa-spin';
break;
default: // info
iconClass = 'fa-info-circle';
$targetElement.addClass('info');
}
var $message = $("<div class='msg'><i class='fa " + iconClass + "'></i> " + msg + "</div>");
$targetElement.empty().append($message); // Clear and append new message
if (timeout > 0) {
setTimeout(function() {
$message.fadeOut(function() {
$(this).remove();
$targetElement.fadeOut();
});
}, timeout);
}
}
/**
* Specific function for artist messages (if still needed, otherwise consolidate into showMessage)
* @param {string} msg - The message to display.
*/
function showArtistMessage(msg) {
showMessage(msg, 'loading', 0, $ajaxMsg2); // No timeout for loading message
}
return {
show: showMessage,
showArtist: showArtistMessage // Consider removing if not distinct enough
};
})();
// --- API / AJAX Module ---
Headphones.API = (function() {
/**
* Performs an AJAX call with standardized messaging and reload options.
* @param {object} options - Configuration options for the AJAX call.
* @param {string} options.url - The URL for the AJAX request.
* @param {object|string} [options.data] - Data to send with the request.
* @param {string} [options.type='POST'] - HTTP method ('GET' or 'POST').
* @param {string} [options.successMessage='Success!'] - Message to display on success.
* @param {string} [options.errorMessage='There was an error'] - Message to display on error.
* @param {string} [options.reloadType] - Type of reload after success ('table', 'tabs', 'page', 'submenu', 'submenu&table').
* @param {HTMLElement} [options.contextElement] - The DOM element that triggered the action (for data-attributes).
* @param {function} [options.beforeSendCallback] - Custom function to call before sending.
* @param {function} [options.successCallback] - Custom function to call on success.
* @param {function} [options.errorCallback] - Custom function to call on error.
* @param {function} [options.completeCallback] - Custom function to call on completion.
*/
function doAjaxCall(options) {
var opts = $.extend(true, {
url: '',
data: {},
type: 'POST',
successMessage: 'Success!',
errorMessage: 'There was an error',
reloadType: null, // 'table', 'tabs', 'page', 'submenu', 'submenu&table'
contextElement: null,
beforeSendCallback: null,
successCallback: null,
errorCallback: null,
completeCallback: null
}, options);
// Get messages from data attributes if context element provided
if (opts.contextElement) {
var $elem = $(opts.contextElement);
opts.successMessage = $elem.data('success') || opts.successMessage;
opts.errorMessage = $elem.data('error') || opts.errorMessage;
}
$.ajax({
url: opts.url,
data: opts.data,
type: opts.type,
beforeSend: function(jqXHR, settings) {
Headphones.UI.Message.show('Loading...', 'loading', 0); // Show loading message indefinitely
if (opts.beforeSendCallback) opts.beforeSendCallback(jqXHR, settings);
},
error: function(jqXHR, textStatus, errorThrown) {
Headphones.UI.Message.show(opts.errorMessage, 'error');
if (opts.errorCallback) opts.errorCallback(jqXHR, textStatus, errorThrown);
},
success: function(data, textStatus, jqXHR) {
Headphones.UI.Message.show(opts.successMessage, 'success');
if (opts.successCallback) opts.successCallback(data, textStatus, jqXHR);
// Handle reloads based on reloadType
setTimeout(function() { // Wait for message to fade out
if (opts.reloadType === 'table') {
Headphones.UI.Content.refreshTable();
} else if (opts.reloadType === 'tabs') {
Headphones.UI.Content.refreshTab();
} else if (opts.reloadType === 'page') {
location.reload();
} else if (opts.reloadType === 'submenu') {
Headphones.UI.Content.refreshSubmenu();
} else if (opts.reloadType === 'submenu&table') {
Headphones.UI.Content.refreshSubmenu();
Headphones.UI.Content.refreshTable();
}
}, Headphones.config.messageTimeout + 100); // Wait for message fade out
},
complete: function(jqXHR, textStatus) {
// The message will fade out automatically by showMessage
if (opts.completeCallback) opts.completeCallback(jqXHR, textStatus);
}
});
}
/**
* Performs a simple GET AJAX call without complex messaging or reload.
* @param {string} url - The URL for the GET request.
*/
function doSimpleAjaxCall(url) {
$.ajax({ url: url, type: 'GET' });
}
return {
call: doAjaxCall,
simpleCall: doSimpleAjaxCall
};
})();
// --- Image Loading Module ---
Headphones.Images = (function() {
/**
* Loads artwork/thumbnail for an element, handling lazy load and fallbacks.
* @param {jQuery} $imgElem - The jQuery image element.
* @param {string} id - The ID (AlbumID or ArtistID).
* @param {string} type - 'album' or 'artist'.
* @param {boolean} [unveil=false] - Whether to use data-src and unveil for lazy loading.
*/
function loadImage($imgElem, id, type, unveil) {
var infoURL = "getImageLinks?" + (type === 'artist' ? "ArtistID=" : "AlbumID=") + id;
$.ajax({
url: infoURL,
cache: true,
dataType: "json",
success: function(data) {
var imageUrl = Headphones.config.fallbackImage;
var artworkUrl = Headphones.config.fallbackImage;
if (data) {
imageUrl = data.thumbnail || (type === 'artist' ? Headphones.config.fallbackArtistImage : Headphones.config.fallbackImage);
artworkUrl = data.artwork || (type === 'artist' ? Headphones.config.fallbackArtistImage : Headphones.config.fallbackImage);
}
if (unveil) {
$imgElem.attr("data-src", imageUrl);
$imgElem.unveil(); // Trigger unveil for lazy loading
} else {
$imgElem.attr("src", imageUrl).hide().fadeIn(); // Direct load with fade
}
// If element has a rel="dialog" parent, update href for Fancybox
var $wrapper = $imgElem.closest('a[rel="dialog"]');
if ($wrapper.length) {
$wrapper.attr('href', artworkUrl); // Link to full artwork
}
},
error: function() {
// On error, set to fallback image if not already set or handled by onerror on HTML
if (unveil) {
$imgElem.attr("data-src", Headphones.config.fallbackImage);
$imgElem.unveil();
} else {
$imgElem.attr("src", Headphones.config.fallbackImage).hide().fadeIn();
}
}
});
}
/**
* Fetches and appends summary information.
* @param {jQuery} $elem - The element to append summary to.
* @param {string} id - The ID (AlbumID or ArtistID).
* @param {string} type - 'album' or 'artist'.
*/
function getInfo($elem, id, type) {
var infoURL = "getInfo?" + (type === 'artist' ? "ArtistID=" : "AlbumID=") + id;
$.ajax({
url: infoURL,
cache: true,
dataType: "json",
success: function(data) {
if (data && data.Summary) {
$elem.append(data.Summary);
}
}
});
}
return {
load: loadImage,
getInfo: getInfo
};
})();
// --- UI Content Refresh Module ---
Headphones.UI.Content = (function() {
/**
* Refreshes a specific part of the page content.
* @param {string} targetSelector - jQuery selector for the element to load content into.
* @param {string} sourceContentSelector - jQuery selector for the content to extract from the source URL.
* @param {function} [initCallback] - Callback function to run after content is loaded.
*/
function refreshPart(targetSelector, sourceContentSelector, initCallback) {
var url = window.location.href;
$(targetSelector).load(url + " " + sourceContentSelector, function(response, status, xhr) {
if (status === "error") {
console.error("Failed to load content for " + targetSelector + ": " + xhr.status + " " + xhr.statusText);
Headphones.UI.Message.show("Failed to refresh content.", 'error');
} else if (initCallback) {
// Ensure the callback is part of the Headphones object if it relies on it
if (typeof initCallback === 'string' && Headphones.hasOwnProperty(initCallback)) {
Headphones[initCallback](); // Call the global Headphones function
} else if (typeof initCallback === 'function') {
initCallback();
}
}
});
}
/** Refreshes the submenu by reloading its content. */
function refreshSubmenu() {
refreshPart("#subhead_container", "#subhead_menu", Headphones.UI.initActions); // Pass initActions to re-apply handlers
}
/** Refreshes the main table content. */
function refreshTable() {
// This assumes there's one main .display table. If multiple, refine selector.
// The original also reloaded tbody and thead separately, which is fine.
refreshPart("table.display", "table.display tbody, table.display thead", function() {
// Re-initialize DataTables after table refresh.
// This might need specific DataTable re-initialization logic for each page.
// A more robust solution would be to destroy and recreate the DataTable.
// The template-specific `initThisPage()` usually contains this.
// We need to call the appropriate initThisPage for the current page.
// As `initThisPage` is defined per HTML file, we rely on the specific page's ready handler
// to call its own initThisPage, or pass it as a parameter if this is called generically.
// For now, let's assume `initThisPage` exists in the global scope where refreshTable is called.
if (typeof initThisPage === 'function') {
initThisPage();
} else if (typeof SearchResultsPage !== 'undefined' && typeof SearchResultsPage.initDataTable === 'function') {
SearchResultsPage.initDataTable();
}
// ... add more page-specific re-initializations as needed
});
}
/** Refreshes the currently active tab content. */
function refreshTab() {
var tabId = $('.ui-tabs-panel:visible').attr("id");
if (tabId) {
refreshPart('.ui-tabs-panel:visible', "#" + tabId, function() {
// Similar to refreshTable, re-initialize specific tab content
if (typeof initThisPage === 'function') { // assuming initThisPage handles tab content
initThisPage();
}
});
}
}
/**
* Polls for status changes on rows with 'gradeL' class (loading state).
* This is an older polling pattern; consider WebSockets for real-time updates if performance is critical.
*/
function refreshLoadArtist() {
var $loadingRows = $("table.display tr.gradeL");
if ($loadingRows.length > 0) {
$loadingRows.each(function() {
var $row = $(this);
// Reload only the row's content to check status
var rowIndex = $row.index() + 1; // 1-based index
var url = window.location.href;
$row.load(url + " table.display tbody tr:nth-child(" + rowIndex + ") td", function(response, status, xhr) {
if (status === "error") {
console.error("Failed to load row status: " + xhr.status + " " + xhr.statusText);
// Optional: Show a message to the user
} else {
// Check status after loading the updated row content
if ($row.find(".column-status").text() === 'Active') { // Assuming a status column with this text
$row.removeClass('gradeL').addClass('gradeZ');
// Re-initialize relevant JS if needed for the row (e.g., DataTables redrawing)
if (typeof initThisPage === 'function') initThisPage(); // Re-initialize the table/page
} else {
// If still loading, set timeout for next check
setTimeout(refreshLoadArtist, 3000);
}
}
});
});
}
}
return {
refreshSubmenu: refreshSubmenu,
refreshTable: refreshTable,
refreshTab: refreshTab,
refreshLoadArtist: refreshLoadArtist
};
})();
// --- UI Element Initialization Module ---
Headphones.UI.Elements = (function() {
/** Initializes the header scroll-fade effect. */
function initHeader() {
var $header = $("#container header");
var fadeSpeed = 100,
fadeTo = 0.5,
topDistance = 20;
var inside = false;
$(window).on('scroll', function() {
var position = $(window).scrollTop();
if (position > topDistance && !inside) {
//add events
$header.fadeTo(fadeSpeed, fadeTo);
$header.on('mouseenter', function() {
$header.fadeTo(fadeSpeed, 1);
});
$header.on('mouseleave', function() {
$header.fadeTo(fadeSpeed, fadeTo);
});
$("#toTop").fadeIn();
inside = true;
} else if (position < topDistance && inside) { // Added '&& inside' to prevent re-triggering
$header.fadeTo(fadeSpeed, 1); // Ensure it's fully visible at top
$header.off('mouseenter mouseleave'); // Remove events when at top
$("#toTop").fadeOut();
inside = false;
}
});
}
/**
* Initializes config checkboxes to show/hide sibling content.
* @param {HTMLElement} elem - The checkbox DOM element.
*/
function initConfigCheckbox(elem) {
var $checkbox = $(elem);
var $configContent = $checkbox.parent().next();
// Initial state
if ($checkbox.is(":checked")) {
$configContent.show();
} else {
$configContent.hide();
}
// Click handler
$checkbox.on('click', function() {
if ($(this).is(":checked")) {
$configContent.slideDown();
} else {
$configContent.slideUp();
}
});
}
/** Initializes various action buttons with jQuery UI button styling. */
function initActions() {
// Select all menu links under #subhead_menu and apply button()
// This assumes jQuery UI Button widget is available and desired.
$("#subhead_menu a[id^='menu_link_'], #subhead_menu .menu_link_edit").button();
}
return {
initHeader: initHeader,
initConfigCheckbox: initConfigCheckbox,
initActions: initActions
};
})();
// --- Utility Functions Module ---
Headphones.Utils = (function() {
/**
* Sets placeholder text for DataTables search input.
* @param {string} text - The placeholder text.
*/
function resetFilters(text) {
// Ensure this targets the correct search input for DataTables
if ($(".dataTables_filter input").length > 0) {
$(".dataTables_filter input").attr("placeholder", "filter " + text + "");
}
}
/** Initializes Fancybox for dialog links. */
function initFancybox() {
// Check if Fancybox script is already loaded or needs to be loaded dynamically
if ($.fn.fancybox) { // Check if fancybox function exists on jQuery object
$("a[rel=dialog]").fancybox();
} else if ($("a[rel=dialog]").length > 0) {
// Dynamically load Fancybox script and CSS if elements with rel=dialog exist
$.getScript('interfaces/default/js/fancybox/jquery.fancybox-1.3.4.js', function() {
$("head").append("<link rel='stylesheet' href='interfaces/default/js/fancybox/jquery.fancybox-1.3.4.css'>");
$("a[rel=dialog]").fancybox();
}).fail(function(jqxhr, settings, exception) {
console.error("Failed to load Fancybox script: ", exception);
});
}
}
return {
resetFilters: resetFilters,
initFancybox: initFancybox
};
})();
// --- Global Functions (for backward compatibility if needed, but prefer to call from Headphones object) ---
// These global functions would ideally be removed and replaced with direct calls to Headphones.API, Headphones.UI, etc.
// For a gradual transition, they can act as wrappers.
// Original getThumb / getImageLinks replacement
function getImageLinks(elem, id, type, unveil) {
Headphones.Images.load($(elem), id, type, unveil);
} }
// Original getInfo replacement function getArtwork(imgElem,id,name,type) {
function getInfo(elem, id, type) {
Headphones.Images.getInfo($(elem), id, type); if ( type == 'artist' ) {
var artworkURL = "getArtwork?ArtistID=" + id;
} else {
var artworkURL = "getArtwork?AlbumID=" + id;
}
// Get Data from the cache by Artist ID
$.ajax({
url: artworkURL,
cache: true,
success: function(data){
if ( data == "" || data == undefined ) {
var imageUrl = "interfaces/default/images/no-cover-artist.png";
}
else {
var imageUrl = data;
}
$(imgElem).attr("src",imageUrl).hide().fadeIn();
$(imgElem).wrap('<a href="'+ imageUrl +'" rel="dialog" title="' + name + '"></a>');
}
});
} }
// Original doAjaxCall replacement (needs careful integration with new params) function getInfo(elem,id,type) {
// The HTML templates are passing doAjaxCall(url,elem,reload,form).
// This wrapper needs to convert those to the new options object. if ( type == 'artist' ) {
function doAjaxCall(url, elem, reloadType, isFormSubmission) { var infoURL = "getInfo?ArtistID=" + id;
var options = { } else {
url: url, var infoURL = "getInfo?AlbumID=" + id;
contextElement: elem, }
reloadType: reloadType // Get Data from the cache by ID
}; $.ajax({
url: infoURL,
cache: true,
dataType: "json",
success: function(data){
var summary = data.Summary;
$(elem).append(summary);
}
});
}
if (isFormSubmission) { function getImageLinks(elem,id,type,unveil) {
// This part needs careful migration. The original `doAjaxCall` if ( type == 'artist' ) {
// had logic like `var formID = "#"+url; var dataString = $(formID).serialize();` var infoURL = "getImageLinks?ArtistID=" + id;
// and validation like `if ( $('td#select input[type=checkbox]').length > 0 && !$('td#select input[type=checkbox]').is(':checked') ... )`. } else {
// This validation MUST be done *before* calling doAjaxCall in the specific HTML template's JS. var infoURL = "getImageLinks?AlbumID=" + id;
// Here, we assume `url` is the form ID if `isFormSubmission` is true. }
var $form = $('#' + url); // Assuming url is the form ID
if ($form.length) { // Get Data from the cache by ID
options.data = $form.serialize(); $.ajax({
} else { url: infoURL,
console.warn("doAjaxCall: form with ID '" + url + "' not found for submission."); cache: true,
// If the form cannot be found, it should likely be an error. dataType: "json",
Headphones.UI.Message.show("Form not found for submission.", 'error'); success: function(data){
return false; if (!data) {
} // Invalid response
} return;
Headphones.API.call(options); }
if (!data.thumbnail) {
var thumbnail = "interfaces/default/images/no-cover-artist.png";
}
else {
var thumbnail = data.thumbnail;
}
if (!data.artwork) {
var artwork = "interfaces/default/images/no-cover-artist.png";
}
else {
var artwork = data.artwork;
}
if (unveil) {
$(elem).attr("data-src", thumbnail);
$(elem).unveil();
}
else {
$(elem).attr("src", thumbnail);
}
}
});
}
function initHeader() {
//settings
var header = $("#container header");
var fadeSpeed = 100, fadeTo = 0.5, topDistance = 20;
var topbarME = function() { $(header).fadeTo(fadeSpeed,1); }, topbarML = function() { $(header).fadeTo(fadeSpeed,fadeTo); };
var inside = false;
//do
$(window).scroll(function() {
position = $(window).scrollTop();
if(position > topDistance && !inside) {
//add events
topbarML();
$(header).bind('mouseenter',topbarME);
$(header).bind('mouseleave',topbarML);
$("#toTop").fadeIn();
inside = true;
}
else if (position < topDistance){
topbarME();
$(header).unbind('mouseenter',topbarME);
$(header).unbind('mouseleave',topbarML);
$("#toTop").fadeOut();
inside = false;
}
});
}
function initConfigCheckbox(elem) {
var config = $(elem).parent().next();
if ( $(elem).is(":checked") ) {
config.show();
} else {
config.hide();
}
$(elem).click(function(){
var config = $(this).parent().next();
if ( $(this).is(":checked") ) {
config.slideDown();
} else {
config.slideUp();
}
});
}
function initActions() {
$("#subhead_menu #menu_link_refresh").button();
$("#subhead_menu #menu_link_edit").button();
$("#subhead_menu .menu_link_edit").button();
$("#subhead_menu #menu_link_delete" ).button();
$("#subhead_menu #menu_link_pauze").button();
$("#subhead_menu #menu_link_resume").button();
$("#subhead_menu #menu_link_getextra").button();
$("#subhead_menu #menu_link_removeextra").button();
$("#subhead_menu #menu_link_wanted" ).button();
$("#subhead_menu #menu_link_check").button();
$("#subhead_menu #menu_link_skipped").button();
$("#subhead_menu #menu_link_retry").button();
$("#subhead_menu #menu_link_new").button();
$("#subhead_menu #menu_link_shutdown").button();
$("#subhead_menu #menu_link_scan").button();
}
function refreshSubmenu() {
var url = $(location).attr('href');
$("#subhead_container").load(url + " #subhead_menu",function(){
initActions();
});
}
function refreshTable() {
var url = $(location).attr('href');
$("table.display").load(url + " table.display tbody, table.display thead", function() {
initThisPage();
});
}
function refreshLoadArtist() {
if ( $(".gradeL").length > 0 ) {
var url = $(location).attr('href');
var loadingRow = $("table.display tr.gradeL")
loadingRow.each(function(){
var row = $(this).index() + 1;
var rowLoad = $("table.display tbody tr:nth-child("+row+")");
$(rowLoad).load(url + " table.display tbody tr:nth-child("+ row +") td", function() {
if ( $(rowLoad).children("#status").text() == 'Active' ) {
// Active
$(rowLoad).removeClass('gradeL').addClass('gradeZ');
initThisPage();
} else {
// Still loading
setTimeout(function(){
refreshLoadArtist();
},3000);
}
});
});
}
}
function refreshTab() {
var url = $(location).attr('href');
var tabId = $('.ui-tabs-panel:visible').attr("id");
$('.ui-tabs-panel:visible').load(url + " #"+ tabId, function() {
initThisPage();
});
}
function showMsg(msg,loader,timeout,ms) {
var feedback = $("#ajaxMsg");
update = $("#updatebar");
if ( update.is(":visible") ) {
var height = update.height() + 35;
feedback.css("bottom",height + "px");
} else {
feedback.removeAttr("style");
}
feedback.fadeIn();
var message = $("<div class='msg'>" + msg + "</div>");
if (loader) {
var message = $("<i class='fa fa-refresh fa-spin'></i> " + msg + "</div>");
feedback.css("padding","14px 10px")
}
$(feedback).prepend(message);
if (timeout) {
setTimeout(function(){
message.fadeOut(function(){
$(this).remove();
feedback.fadeOut();
});
},ms);
}
}
function showArtistMsg(msg) {
var feedback = $("#ajaxMsg2");
update = $("#updatebar");
if ( update.is(":visible") ) {
var height = update.height() + 35;
feedback.css("bottom",height + "px");
} else {
feedback.removeAttr("style");
}
feedback.fadeIn();
var message = $("<i class='fa fa-refresh fa-spin'></i> " + msg + "</div>");
feedback.css("padding","14px 10px")
$(feedback).prepend(message);
}
function doAjaxCall(url,elem,reload,form) {
// Set Message
feedback = $("#ajaxMsg");
update = $("#updatebar");
if ( update.is(":visible") ) {
var height = update.height() + 35;
feedback.css("bottom",height + "px");
} else {
feedback.removeAttr("style");
}
feedback.fadeIn();
// Get Form data
var formID = "#"+url;
if ( form == true ) {
var dataString = $(formID).serialize();
}
// Loader Image
var loader = $("<i class='fa fa-refresh fa-spin'></i>");
// Data Success Message
var dataSucces = $(elem).data('success');
if (typeof dataSucces === "undefined") {
// Standard Message when variable is not set
var dataSucces = "Success!";
}
// Data Errror Message
var dataError = $(elem).data('error');
if (typeof dataError === "undefined") {
// Standard Message when variable is not set
var dataError = "There was an error";
}
// Get Success & Error message from inline data, else use standard message
var succesMsg = $("<div class='msg'><i class='fa fa-check'></i> " + dataSucces + "</div>");
var errorMsg = $("<div class='msg'><i class='fa fa-exclamation-triangle'></i> " + dataError + "</div>");
// Check if checkbox is selected
if ( form ) {
if ( $('td#select input[type=checkbox]').length > 0 && !$('td#select input[type=checkbox]').is(':checked') || $('#importLastFM #username:visible').length > 0 && $("#importLastFM #username" ).val().length === 0 ) {
feedback.addClass('error')
$(feedback).prepend(errorMsg);
setTimeout(function(){
errorMsg.fadeOut(function(){
$(this).remove();
feedback.fadeOut(function(){
feedback.removeClass('error');
});
})
$(formID + " select").children('option[disabled=disabled]').attr('selected','selected');
},2000);
return false;
}
}
// Ajax Call
$.ajax({
url: url,
data: dataString,
type: 'POST',
beforeSend: function(jqXHR, settings) {
// Start loader etc.
feedback.prepend(loader);
},
error: function(jqXHR, textStatus, errorThrown) {
feedback.addClass('error')
feedback.prepend(errorMsg);
setTimeout(function(){
errorMsg.fadeOut(function(){
$(this).remove();
feedback.fadeOut(function(){
feedback.removeClass('error')
});
})
},2000);
},
success: function(data,jqXHR) {
feedback.prepend(succesMsg);
feedback.addClass('success')
setTimeout(function(e){
succesMsg.fadeOut(function(){
$(this).remove();
feedback.fadeOut(function(){
feedback.removeClass('success');
});
if ( reload == true ) refreshSubmenu();
if ( reload == "table") {
console.log('refresh'); refreshTable();
}
if ( reload == "tabs") refreshTab();
if ( reload == "page") location.reload();
if ( reload == "submenu&table") {
refreshSubmenu();
refreshTable();
}
if ( form ) {
// Change the option to 'choose...'
$(formID + " select").children('option[disabled=disabled]').attr('selected','selected');
}
})
},2000);
},
complete: function(jqXHR, textStatus) {
// Remove loaders and stuff, ajax request is complete!
loader.remove();
}
});
} }
function doSimpleAjaxCall(url) { function doSimpleAjaxCall(url) {
Headphones.API.simpleCall(url); $.ajax(url);
} }
function resetFilters(text) { function resetFilters(text){
Headphones.Utils.resetFilters(text); if ( $(".dataTables_filter").length > 0 ) {
$(".dataTables_filter input").attr("placeholder","filter " + text + "");
}
} }
function initFancybox() { function initFancybox() {
Headphones.Utils.initFancybox(); if ( $("a[rel=dialog]").length > 0 ) {
$.getScript('interfaces/default/js/fancybox/jquery.fancybox-1.3.4.js', function() {
$("head").append("<link rel='stylesheet' href='interfaces/default/js/fancybox/jquery.fancybox-1.3.4.css'>");
$("a[rel=dialog]").fancybox();
});
}
} }
// Placeholder for `initThisPage` - this function is typically defined per HTML template. $(document).ready(function(){
// It's called by `$(document).ready` in many templates. initHeader();
// This should be removed from `headphones.js` and defined in each template.
// function initThisPage() { /* defined in each HTML template */ }
// --- Document Ready ---
$(document).ready(function() {
Headphones.UI.Elements.initHeader(); // Initialize global header effects
Headphones.UI.Elements.initActions(); // Initialize global action buttons (jQuery UI)
// Ensure `initThisPage()` is called from the individual HTML template's ready handler.
// This `script.js` file should be included *before* the template's specific script block.
}); });
+56 -123
View File
@@ -6,9 +6,8 @@
<%def name="headerIncludes()"> <%def name="headerIncludes()">
<div id="subhead_container"> <div id="subhead_container">
<div id="subhead_menu"> <div id="subhead_menu">
<%-- Changed href to # and added data-action for JS handling --%> <a class="menu_link_edit" href="clearLogs"><i class="fa fa-trash-o"></i> Clear log</a>
<a class="menu_link_action" href="#" data-action="clearLogs" data-success="Logs cleared successfully!"><i class="fa fa-trash-o"></i> Clear log</a> <a class="menu_link_edit" href="toggleVerbose"><i class="fa fa-pencil"></i> Toggle Debug Log</a>
<a class="menu_link_action" href="#" data-action="toggleVerbose" data-success="Log verbosity toggled!"><i class="fa fa-pencil"></i> Toggle Debug Log</a>
</div> </div>
</div> </div>
</%def> </%def>
@@ -20,81 +19,61 @@
<table class="display" id="log_table"> <table class="display" id="log_table">
<thead> <thead>
<tr> <tr>
<th class="column-timestamp">Timestamp</th> <th id="timestamp">Timestamp</th>
<th class="column-level">Level</th> <th id="level">Level</th>
<th class="column-message">Message</th> <th id="message">Message</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
</tbody> </tbody>
</table> </table>
<br> <br>
<div class="refresh-controls" align="center"> <div align="center">Refresh rate:
Refresh rate: <select id="refreshrate" onchange="setRefresh()">
<select id="refreshrate"> <%-- Removed inline onchange --%> <option value="0" selected="selected">No Refresh</option>
<option value="0" selected="selected">No Refresh</option> <option value="5">5 Seconds</option>
<option value="5">5 Seconds</option> <option value="15">15 Seconds</option>
<option value="15">15 Seconds</option> <option value="30">30 Seconds</option>
<option value="30">30 Seconds</option> <option value="60">60 Seconds</option>
<option value="60">60 Seconds</option> <option value="300">5 Minutes</option>
<option value="300">5 Minutes</option> <option value="600">10 Minutes</option>
<option value="600">10 Minutes</option> </select></div>
</select>
</div>
</%def> </%def>
<%def name="headIncludes()"> <%def name="headIncludes()">
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
<link rel="stylesheet" href="interfaces/default/css/data_table.css"> <link rel="stylesheet" href="interfaces/default/css/data_table.css">
<style>
/* Example CSS for log levels */
.gradeX { /* For ERROR */
background-color: #fdd; /* Light red */
}
.gradeW { /* For WARNING */
background-color: #ffc; /* Light yellow */
}
.gradeZ { /* For INFO/DEBUG */
/* default background or light gray */
}
</style>
</%def> </%def>
<%def name="javascriptIncludes()"> <%def name="javascriptIncludes()">
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
<script src="js/libs/jquery.dataTables.min.js"></script> <script src="js/libs/jquery.dataTables.min.js"></script>
<script> <script>
// Encapsulate page-specific logic $(document).ready(function() {
var LogsPage = LogsPage || {}; initActions();
LogsPage.timer = null; // To hold the interval timer ID $('#log_table').dataTable( {
"bProcessing": true,
LogsPage.initDataTable = function() {
$('#log_table').dataTable( {
"bProcessing": true,
"bServerSide": true, "bServerSide": true,
"sAjaxSource": 'getLog', "sAjaxSource": 'getLog',
"sPaginationType": "full_numbers", "sPaginationType": "full_numbers",
"aaSorting": [[0, 'desc']], // Sort by timestamp descending "aaSorting": [[0, 'desc']],
"iDisplayLength": 25, "iDisplayLength": 25,
"bStateSave": true, // Retain table state across page loads "bStateSave": true,
"oLanguage": { "oLanguage": {
"sSearch":"Filter:", "sSearch":"Filter:",
"sLengthMenu":"Show _MENU_ lines per page", "sLengthMenu":"Show _MENU_ lines per page",
"sEmptyTable": "No log information available", "sEmptyTable": "No log information available",
"sInfo":"Showing _START_ to _END_ of _TOTAL_ lines", "sInfo":"Showing _START_ to _END_ of _TOTAL_ lines",
"sInfoEmpty":"Showing 0 to 0 of 0 lines", "sInfoEmpty":"Showing 0 to 0 of 0 lines",
"sInfoFiltered":"(filtered from _MAX_ total lines)" "sInfoFiltered":"(filtered from _MAX_ total lines)"},
},
"fnRowCallback": function (nRow, aData, iDisplayIndex, iDisplayIndexFull) { "fnRowCallback": function (nRow, aData, iDisplayIndex, iDisplayIndexFull) {
// aData[1] contains the 'Level' from the server
if (aData[1] === "ERROR") { if (aData[1] === "ERROR") {
$(nRow).addClass("gradeX"); $('td', nRow).closest('tr').addClass("gradeX");
} else if (aData[1] === "WARNING") { } else if (aData[1] === "WARNING") {
$(nRow).addClass("gradeW"); $('td', nRow).closest('tr').addClass("gradeW");
} else { } else {
$(nRow).addClass("gradeZ"); $('td', nRow).closest('tr').addClass("gradeZ");
} }
return nRow; return nRow;
}, },
"fnDrawCallback": function (o) { "fnDrawCallback": function (o) {
@@ -102,76 +81,30 @@
$('html,body').scrollTop(0); $('html,body').scrollTop(0);
}, },
"fnServerData": function ( sSource, aoData, fnCallback ) { "fnServerData": function ( sSource, aoData, fnCallback ) {
// Custom function for fetching data, using $.getJSON /* Add some extra data to the sender */
$.getJSON(sSource, aoData, function (json) { $.getJSON(sSource, aoData, function (json) {
fnCallback(json); fnCallback(json)
}).fail(function(jqXHR, textStatus, errorThrown) { });
console.error("Error fetching log data:", textStatus, errorThrown); }
// Optional: Display an error message to the user });
});
}
});
};
LogsPage.setRefresh = function() {
var refreshrateSelect = document.getElementById('refreshrate');
if (refreshrateSelect != null) {
// Clear any existing timer
if (LogsPage.timer) {
clearInterval(LogsPage.timer);
}
var refreshValue = parseInt(refreshrateSelect.value, 10);
if (refreshValue !== 0) {
// Set a new interval to redraw the DataTable
LogsPage.timer = setInterval(function() {
$('#log_table').dataTable().fnDraw(false); // 'false' prevents resetting current page
}, 1000 * refreshValue);
}
}
};
LogsPage.initActions = function() {
// Event listener for the refresh rate dropdown
$('#refreshrate').on('change', LogsPage.setRefresh);
// Event delegation for header action links
$('#subhead_menu').on('click', '.menu_link_action', function(e) {
e.preventDefault(); // Prevent default link behavior
var $this = $(this);
var action = $this.data('action'); // 'clearLogs' or 'toggleVerbose'
var successMsg = $this.data('success');
if (typeof doAjaxCall === 'function') {
// Assuming doAjaxCall handles the AJAX request and success/error messages
doAjaxCall('/' + action, $this, 'table', successMsg, function() {
// Callback after successful AJAX call for specific actions
if (action === 'clearLogs') {
// Redraw table after clearing logs to show empty state or refreshed data
$('#log_table').dataTable().fnDraw();
}
// No specific redraw needed for toggleVerbose unless it changes displayed logs
});
} else {
console.error("doAjaxCall function is not defined. Cannot perform log action.");
// Fallback to direct navigation if AJAX utility is missing
// window.location.href = '/' + action;
}
});
};
$(document).ready(function() {
LogsPage.initDataTable();
LogsPage.initActions(); // Initialize new event handlers
// Initial call to set refresh based on default selected option
LogsPage.setRefresh();
// Ensure global initActions from common.js is called if it handles other site-wide actions
if (typeof initActions === 'function') {
initActions();
}
}); });
</script> </script>
<script>
var timer;
function setRefresh()
{
refreshrate = document.getElementById('refreshrate');
if(refreshrate != null)
{
if(timer)
{
clearInterval(timer);
}
if(refreshrate.value != 0)
{
timer = setInterval("$('#log_table').dataTable().fnDraw()",1000*refreshrate.value);
}
}
}
</script>
</%def> </%def>
+115 -224
View File
@@ -6,11 +6,9 @@
<%def name="headerIncludes()"> <%def name="headerIncludes()">
<div id="subhead_container"> <div id="subhead_container">
<div id="subhead_menu"> <div id="subhead_menu">
<%-- Retained id="manage_albums" as it's a specific trigger for a dialog --%> <a class="menu_link_edit" id="manage_albums" href="javascript:void(0)"><i class="fa fa-pencil"></i> Manage Albums</a>
<a class="menu_link_edit" id="manage_albums" href="#"><i class="fa fa-pencil"></i> Manage Albums</a> <div id="dialog" title="Choose Album Filter" style="display:none" class="configtable">
<div id="dialog-manage-albums" title="Choose Album Filter" style="display:none" class="configtable">
<div class="links"> <div class="links">
<%-- Links within dialog changed to use data-status for filtering if AJAX is desired on manageAlbums page --%>
<a href="manageAlbums?Status=Downloaded"><i class="fa fa-check fa-fw"></i> Manage Downloaded Albums</a><br> <a href="manageAlbums?Status=Downloaded"><i class="fa fa-check fa-fw"></i> Manage Downloaded Albums</a><br>
<a href="manageAlbums?Status=Skipped"><i class="fa fa-flag fa-fw"></i> Manage Skipped Albums</a><br> <a href="manageAlbums?Status=Skipped"><i class="fa fa-flag fa-fw"></i> Manage Skipped Albums</a><br>
<a href="manageAlbums?Status=Snatched"><i class="fa fa-cloud-download fa-fw"></i> </span>Manage Snatched Albums</a><br> <a href="manageAlbums?Status=Snatched"><i class="fa fa-cloud-download fa-fw"></i> </span>Manage Snatched Albums</a><br>
@@ -42,9 +40,8 @@
<li><a href="#tabs-4">Force Legacy</a></li> <li><a href="#tabs-4">Force Legacy</a></li>
</ul> </ul>
<div id="tabs-1" class="configtable"> <div id="tabs-1" class="configtable">
<%-- action="musicScan" method="GET" is fine for form submission if full page reload is intended --%> <fieldset>
<form action="musicScan" method="GET" id="musicScan"> <form action="musicScan" method="GET" id="musicScan">
<fieldset>
<legend>Scan Music Library</legend> <legend>Scan Music Library</legend>
<p><strong>Where do you keep your music?</strong></p> <p><strong>Where do you keep your music?</strong></p>
<p>You can put in any directory, and it will scan for audio files in that folder <p>You can put in any directory, and it will scan for audio files in that folder
@@ -55,22 +52,25 @@
</p> </p>
<br/> <br/>
<div class="row"> <div class="row">
<label for="music_dir_path">Path to directory</label> <label for="">Path to directory</label>
<%-- Using HTML5 placeholder attribute and proper ID --%> %if headphones.CONFIG.MUSIC_DIR:
<input type="text" id="music_dir_path" value="${headphones.CONFIG.MUSIC_DIR or ''}" name="path" size="70" placeholder="Enter a Music Directory to scan" /> <input type="text" value="${headphones.CONFIG.MUSIC_DIR}" name="path" size="70" />
%else:
<input type="text" value="Enter a Music Directory to scan" onfocus="if
(this.value==this.defaultValue) this.value='';" name="path" size="70" />
%endif
</div> </div>
<div class="row checkbox"> <div class="row checkbox">
<input type="checkbox" name="libraryscan" id="libraryscan" value="1" ${checked(headphones.CONFIG.LIBRARYSCAN)}><label for="libraryscan">Automatically scan library</label> <input type="checkbox" name="libraryscan" id="libraryscan" value="1" ${checked(headphones.CONFIG.LIBRARYSCAN)}><label>Automatically scan library</label>
</div> </div>
<div class="row checkbox"> <div class="row checkbox">
<input type="checkbox" name="autoadd" id="autoadd" value="1" ${checked(headphones.CONFIG.AUTO_ADD_ARTISTS)}><label for="autoadd">Auto-add new artists</label> <input type="checkbox" name="autoadd" id="autoadd" value="1" ${checked(headphones.CONFIG.AUTO_ADD_ARTISTS)}><label>Auto-add new artists</label>
</div> </div>
</fieldset> </fieldset>
<br> <br>
<%-- Buttons use classes and data attributes for AJAX --%> <input type="button" value="Save Changes and Scan" onclick="addScanAction();doAjaxCall('musicScan',$(this),'tabs',true);return false;" data-success="Changes saved. Library will be scanned">
<input type="button" class="ajax-button" data-action="musicScan" data-scan="1" data-success="Changes saved. Library will be scanned" value="Save Changes and Scan"> <input type="button" value="Save Changes without Scanning Library" onclick="doAjaxCall('musicScan',$(this),'tabs',true);return false;" data-success="Changes Saved Successfully">
<input type="button" class="ajax-button" data-action="musicScan" data-success="Changes Saved Successfully" value="Save Changes without Scanning Library">
</form> </form>
</div> </div>
@@ -81,15 +81,19 @@
<p>Enter the username whose artists you want to import:</p> <p>Enter the username whose artists you want to import:</p>
<br/> <br/>
<div class="row"> <div class="row">
<label for="lastfm_username">Username</label> <label for="">Username</label>
<%-- Using HTML5 placeholder attribute --%> <%
<input type="text" id="lastfm_username" value="${headphones.CONFIG.LASTFM_USERNAME or ''}" placeholder="Last.fm username" name="username" size="18" /> if headphones.CONFIG.LASTFM_USERNAME:
<%-- Changed to use class and data attributes for AJAX --%> lastfmvalue = headphones.CONFIG.LASTFM_USERNAME
<a href="#" class="ajax-link" data-action="importLastFM" data-reset="username" data-success="Last.fm username has been reset"><i class="fa fa-reply"></i> Reset username</a> else:
lastfmvalue = ''
%>
<input type="text" value="${lastfmvalue}" placeholder="Last.fm username" onfocus="if
(this.value==this.defaultValue) this.value='';" name="username" id="username" size="18" />
<a href="javascript:void(0)" onclick="doAjaxCall('importLastFM?username=',$(this),'tabs');return false;" data-success="Last.fm username has been reset"><i class="fa fa-reply"></i> Reset username</a>
</div> </div>
</fieldset> </fieldset>
<%-- Changed to use class and data attributes for AJAX --%> <input type="button" value="Save changes" onclick="doAjaxCall('importLastFM',$(this),'tabs',true);return false;" data-success="Last.fm artists will be imported" data-error="Fill in a last.fm username"/>
<input type="button" class="ajax-button" data-action="importLastFM" data-success="Last.fm artists will be imported" data-error="Fill in a last.fm username" value="Save changes"/>
</form> </form>
<br/> <br/>
<form action="importLastFMTag" method="GET" id="importLastFMTag"> <form action="importLastFMTag" method="GET" id="importLastFMTag">
@@ -98,15 +102,16 @@
<p>Enter tag from which you want import top artists:</p> <p>Enter tag from which you want import top artists:</p>
<br/> <br/>
<div class="row"> <div class="row">
<label for="lastfm_tag">Tag</label> <label>Tag</label>
<input type="text" id="lastfm_tag" value="" name="tag" size="18" placeholder="Enter tag"/> <input type="text" value="" onfocus="if
(this.value==this.defaultValue) this.value='';" name="tag" id="tag" size="18" />
<br/> <br/>
<label for="lastfm_limit">Limit</label> <label>Limit</label>
<input type="text" id="lastfm_limit" value="50" name="limit" size="18" placeholder="50"/> <input type="text" value="50" onfocus="if
(this.value==this.defaultValue) this.value='';" name="limit" id="limit" size="18" />
</div> </div>
</fieldset> </fieldset>
<%-- Standard submit button for this form --%> <input type="submit" />
<input type="submit" value="Import Tag"/>
</form> </form>
</div> </div>
@@ -116,43 +121,40 @@
<fieldset> <fieldset>
<legend>Force Search</legend> <legend>Force Search</legend>
<div class="links"> <div class="links">
<%-- All links use classes and data attributes for AJAX --%> <a href="javascript:void(0)" onclick="doAjaxCall('forceSearch',$(this))" data-success="Checking for wanted albums successful" data-error="Error checking wanted albums"><i class="fa fa-search fa-fw"></i> Force Check for Wanted Albums</a>
<a href="#" class="ajax-link" data-action="forceSearch" data-success="Checking for wanted albums successful" data-error="Error checking wanted albums"><i class="fa fa-search fa-fw"></i> Force Check for Wanted Albums</a> <a href="javascript:void(0)" onclick="doAjaxCall('forceUpdate',$(this))" data-success="Update active artists successful" data-error="Error forcing update artists"><i class="fa fa-heart fa-fw"></i> Force Update Active Artists [Fast]</a>
<a href="#" class="ajax-link" data-action="forceUpdate" data-success="Update active artists successful" data-error="Error forcing update artists"><i class="fa fa-heart fa-fw"></i> Force Update Active Artists [Fast]</a> <a href="javascript:void(0)" onclick="doAjaxCall('checkGithub',$(this))" data-success="Checking for update successful" data-error="Error checking for update"><i class="fa fa-refresh fa-fw"></i> Check for Headphones Updates</a>
<a href="#" class="ajax-link" data-action="checkGithub" data-success="Checking for update successful" data-error="Error checking for update"><i class="fa fa-refresh fa-fw"></i> Check for Headphones Updates</a> <a href="javascript:void(0)" id="delete_empty_artists"><i class="fa fa-trash-o fa-fw"></i> Delete empty Artists</a>
<div id="emptyartistdialog" title="Confirm Artist Deletion" style="display:none" class="configtable">
<a href="#" class="open-dialog-trigger" data-dialog-id="dialog-empty-artists"><i class="fa fa-trash-o fa-fw"></i> Delete empty Artists</a>
<div id="dialog-empty-artists" title="Confirm Artist Deletion" style="display:none" class="configtable">
%if emptyArtists: %if emptyArtists:
<h3>The following artists will be deleted:</h3> <h3>The following artists will be deleted:</h3>
%for emptyArtist in emptyArtists: %for emptyArtist in emptyArtists:
<p>${emptyArtist['ArtistName']}</p> <p>${emptyArtist['ArtistName']}</p>
%endfor %endfor
<%-- Button uses class and data attributes for AJAX --%> <input type="button" value="Delete Empty Artists" onclick="doAjaxCall('deleteEmptyArtists',$(this))" data-success="Empty Artists deleted" data-error="Error deleting empty artists">
<input type="button" class="ajax-button" data-action="deleteEmptyArtists" data-success="Empty Artists deleted" data-error="Error deleting empty artists" value="Delete Empty Artists">
%else: %else:
<p>No empty artists found.</p> No empty artists found.
%endif %endif
</div> </div>
<div id="post_process"> <div id="post_process">
<a href="#" class="open-dialog-trigger" data-dialog-id="dialog-post-process"><i class="fa fa-wrench fa-fw"></i> Force Post-Process Albums in Download Folder</a> <a href="javascript:void(0)" class="btnOpenDialog"><i class="fa fa-wrench fa-fw"></i> Force Post-Process Albums in Download Folder</a>
</div> </div>
</div> </div>
</fieldset> </fieldset>
<fieldset> <fieldset>
<div class="row" id="post_process_alternate"> <div class="row" id="post_process_alternate">
<label for="alt_dir_path">Force Post-Process Albums in Alternate Folder</label> <label>Force Post-Process Albums in Alternate Folder</label>
<input type="text" value="" name="dir" id="alt_dir_path" size="50" placeholder="Enter alternate directory" /> <input type="text" value="" name="dir" id="dir" size="50" />
<input type="button" class="open-dialog-trigger ajax-dialog-submit" data-dialog-id="dialog-post-process" data-input-id="alt_dir_path" data-input-param="dir" value="Submit" /> <input type="button" class="btnOpenDialog" value="Submit" />
</div> </div>
</fieldset> </fieldset>
<fieldset> <fieldset>
<div class="row" id="post_process_single"> <div class="row" id="post_process_single">
<label for="album_dir_path">Post-Process Single Folder</label> <label>Post-Process Single Folder</label>
<input type="text" value="" name="album_dir" id="album_dir_path" size="50" placeholder="Enter album directory" /> <input type="text" value="" name="album_dir" id="album_dir" size="50" />
<input type="button" class="open-dialog-trigger ajax-dialog-submit" data-dialog-id="dialog-post-process" data-input-id="album_dir_path" data-input-param="album_dir" value="Submit" /> <input type="button" class="btnOpenDialog" value="Submit" />
</div> </div>
</fieldset> </fieldset>
@@ -164,200 +166,89 @@
<legend>Force Legacy</legend> <legend>Force Legacy</legend>
<p>Please note that these functions will take a significant amount of time to complete.</p> <p>Please note that these functions will take a significant amount of time to complete.</p>
<div class="links"> <div class="links">
<%-- All links use classes and data attributes for AJAX --%> <a href="javascript:void(0)" onclick="doAjaxCall('forceFullUpdate',$(this))" data-success="Update active artists successful" data-error="Error forcing update artists"><i class="fa fa-heart fa-fw"></i> Force Update Active Artists [Comprehensive]</a>
<a href="#" class="ajax-link" data-action="forceFullUpdate" data-success="Update active artists successful" data-error="Error forcing update artists"><i class="fa fa-heart fa-fw"></i> Force Update Active Artists [Comprehensive]</a>
<BR> <BR>
<a href="#" class="ajax-link" data-action="forceScan" data-success="Library scan successful" data-error="Error forcing library scan"><i class="fa fa-refresh fa-fw"></i> Force Re-scan Library [Comprehensive]</a> <a href="javascript:void(0)" onclick="doAjaxCall('forceScan',$(this))" data-success="Library scan successful" data-error="Error forcing library scan"><i class="fa fa-refresh fa-fw"></i> Force Re-scan Library [Comprehensive]</a>
<BR> <BR>
<small>*Warning: If you choose [Force Re-scan Library], any manually ignored/matched artists/albums will be reset to "unmatched".</small> <small>*Warning: If you choose [Force Re-scan Library], any manually ignored/matched artists/albums will be reset to "unmatched".</small>
</div> </div>
</fieldset> </fieldset>
</div> </div>
<div id="dialog-confirm"></div>
<%-- Shared dialog for post-process confirmation --%>
<div id="dialog-post-process" title="Keep original folder(s)?" style="display:none">
<p>Do you want to keep the original folder(s) after post-processing? If you click no, the folders still might be kept depending on your global settings</p>
</div>
</div> </div>
</%def> </%def>
<%def name="javascriptIncludes()"> <%def name="javascriptIncludes()">
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
<script> <script>
// Encapsulate page-specific logic function addScanAction() {
var ManagePage = ManagePage || {}; $('#autoadd').append('<input type="hidden" name="scan" value=1 />');
ManagePage.init = function() {
// Initialize jQuery UI Tabs
jQuery( "#tabs" ).tabs();
// Initialize jQuery UI Dialogs
$('#dialog-manage-albums').dialog({
autoOpen: false,
modal: true,
width: 450,
height: 'auto',
title: "Choose Album Filter"
});
$('#dialog-empty-artists').dialog({
autoOpen: false,
modal: true,
width: 500,
height: 'auto',
title: "Confirm Artist Deletion"
});
// Post-process confirmation dialog
$('#dialog-post-process').dialog({
autoOpen: false,
resizable: false,
modal: true,
height: 170,
width: 400,
title: "Keep original folder(s)?",
buttons: {
"Yes": function () {
var $dialog = $(this);
$dialog.dialog('close');
// Retrieve parameters stored on the trigger element
var $trigger = $dialog.data('triggerElement');
var url = $trigger.data('ajax-url') + "&keep_original_folder=True";
var successMsg = $trigger.data('success') || "Post-Processor is being loaded";
var errorMsg = $trigger.data('error') || "Error during Post-Processing";
if (typeof doAjaxCall === 'function') {
doAjaxCall(url, $trigger, 'tabs', successMsg, errorMsg);
} else {
console.error("doAjaxCall function is not defined. Cannot perform post-process action.");
}
},
"No": function () {
var $dialog = $(this);
$dialog.dialog('close');
// Retrieve parameters stored on the trigger element
var $trigger = $dialog.data('triggerElement');
var url = $trigger.data('ajax-url') + "&keep_original_folder=False";
var successMsg = $trigger.data('success') || "Post-Processor is being loaded";
var errorMsg = $trigger.data('error') || "Error during Post-Processing";
if (typeof doAjaxCall === 'function') {
doAjaxCall(url, $trigger, 'tabs', successMsg, errorMsg);
} else {
console.error("doAjaxCall function is not defined. Cannot perform post-process action.");
}
}
}
});
// Event delegation for opening dialogs
$(document).on('click', '.open-dialog-trigger', function(e) {
e.preventDefault();
var dialogId = $(this).data('dialog-id');
var $dialog = $('#' + dialogId);
// For post-process dialogs, build the base URL and store it
if (dialogId === 'dialog-post-process') {
var url = "forcePostProcess?";
var inputId = $(this).data('input-id');
var inputParam = $(this).data('input-param');
if (inputId && inputParam) {
var inputValue = $('#' + inputId).val();
if (inputValue) {
url += inputParam + "=" + encodeURIComponent(inputValue) + "&";
}
}
// Store the base URL and the triggering element on the dialog for use in buttons
$dialog.data('ajax-url', url);
$dialog.data('triggerElement', $(this));
}
$dialog.dialog('open');
});
// Event delegation for general AJAX buttons (Save Changes, Delete Empty Artists)
$(document).on('click', '.ajax-button', function(e) {
e.preventDefault();
var $this = $(this);
var action = $this.data('action'); // e.g., musicScan, deleteEmptyArtists
var successMsg = $this.data('success');
var errorMsg = $this.data('error');
var formId = $this.closest('form').attr('id');
var url = '/' + action;
var data = {};
// For form submissions, serialize the form data
if (formId) {
var formData = $('#' + formId).serializeArray();
$.each(formData, function() {
if (data[this.name]) {
if (!data[this.name].push) {
data[this.name] = [data[this.name]];
}
data[this.name].push(this.value || '');
} else {
data[this.name] = this.value || '';
}
});
}
// Add specific data attributes from the button if present
$.each($this.data(), function(key, value) {
// Exclude 'action', 'success', 'error', 'scan' (handled specifically)
if (key !== 'action' && key !== 'success' && key !== 'error' && key !== 'scan') {
data[key] = value;
}
});
// Special handling for 'scan' parameter for musicScan
if ($this.data('scan') === 1) {
data['scan'] = 1;
}
// Special handling for 'reset' parameter for importLastFM
if ($this.data('reset') === 'username') {
url += '?username='; // Append empty username to clear it
data = {}; // Clear other data if resetting
}
if (typeof doAjaxCall === 'function') {
// Assuming doAjaxCall takes URL, trigger, context, successMsg, errorMsg, and data
doAjaxCall(url, $this, 'tabs', successMsg, errorMsg, data);
} else {
console.error("doAjaxCall function is not defined. Cannot perform AJAX button action.");
}
// If it's a delete artists action, close the dialog
if (action === 'deleteEmptyArtists') {
$('#dialog-empty-artists').dialog('close');
// Optionally, refresh the list of empty artists if it's dynamic
}
});
// Event delegation for general AJAX links (Force Actions, Force Legacy, Reset Last.fm)
$(document).on('click', '.ajax-link', function(e) {
e.preventDefault();
var $this = $(this);
var action = $this.data('action');
var successMsg = $this.data('success');
var errorMsg = $this.data('error');
var url = '/' + action; // Base URL
if (typeof doAjaxCall === 'function') {
doAjaxCall(url, $this, null, successMsg, errorMsg); // No specific context needed like 'tabs' here unless doAjaxCall uses it.
} else {
console.error("doAjaxCall function is not defined. Cannot perform AJAX link action.");
}
});
// Call initActions if it's a global function that needs to run
if (typeof initActions === 'function') {
initActions();
}
}; };
function fnOpenNormalDialog(id) {
if (id === "post_process"){
var url = "forcePostProcess?"
}
if (id === "post_process_alternate"){
var dir = $('#dir').val();
var url = "forcePostProcess?dir=" + dir + "&"
}
if (id === "post_process_single"){
var dir = $('#album_dir').val();
var url = "forcePostProcess?album_dir=" + dir + "&"
}
var t = $('<a data-success="Post-Processor is being loaded" data-error="Error during Post-Processing">');
$("#dialog-confirm").html("Do you want to keep the original folder(s) after post-processing? If you click no, the folders still might be kept depending on your global settings");
// Define the Dialog and its properties.
$("#dialog-confirm").dialog({
resizable: false,
modal: true,
title: "Keep original folder(s)?",
height: 170,
width: 400,
buttons: {
"Yes": function () {
$(this).dialog('close');
doAjaxCall(url + "keep_original_folder=True", t);
},
"No": function () {
$(this).dialog('close');
doAjaxCall(url + "keep_original_folder=False", t);
}
}
});
}
$('.btnOpenDialog').click(function(e){
e.preventDefault();
var parentId = $(this).closest('div').prop('id');
fnOpenNormalDialog(parentId);
});
function callback(value) {
if (value) {
alert("Confirmed");
} else {
alert("Rejected");
}
}
function initThisPage() {
$('#manage_albums').click(function() {
$('#dialog').dialog();
return false;
});
$('#delete_empty_artists').click(function() {
$('#emptyartistdialog').dialog();
return false;
});
jQuery( "#tabs" ).tabs();
initActions();
};
$(document).ready(function() { $(document).ready(function() {
ManagePage.init(); initThisPage();
}); });
</script> </script>
</%def> </%def>
+69 -169
View File
@@ -1,8 +1,7 @@
<%inherit file="base.html" /> <%inherit file="base.html" />
<%! <%!
# Removed direct DB imports/interactions here, as data should be pre-fetched server-side. from headphones import db
# from headphones import db import headphones
import headphones # Still needed for headphones.LOSSY_MEDIA_FORMATS if used
%> %>
<%def name="headerIncludes()"> <%def name="headerIncludes()">
@@ -18,10 +17,9 @@
<div id="manageheader" class="title"> <div id="manageheader" class="title">
<h1 class="clearfix"><i class="fa fa-music"></i> Manage Albums</h1> <h1 class="clearfix"><i class="fa fa-music"></i> Manage Albums</h1>
</div> </div>
<form action="markAlbums" method="get" id="markAlbumsForm"> <%-- Renamed ID to avoid conflict with markalbum div --%> <form action="markAlbums" method="get" id="markAlbums">
<div id="markalbum">Mark selected albums as <div id="markalbum">Mark selected albums as
<%-- Replaced inline onChange with a class and data attributes for JS handling --%> <select name="action" onChange="doAjaxCall('markAlbums',$(this),'table',true);" data-error="You didn't select any albums">
<select name="action" id="markAlbumActionSelect">
<option disabled="disabled" selected="selected">Choose...</option> <option disabled="disabled" selected="selected">Choose...</option>
<option value="Wanted">Wanted</option> <option value="Wanted">Wanted</option>
<option value="WantedNew">Wanted (new only)</option> <option value="WantedNew">Wanted (new only)</option>
@@ -30,27 +28,25 @@
<option value="Ignored">Ignored</option> <option value="Ignored">Ignored</option>
<option value="Downloaded">Downloaded</option> <option value="Downloaded">Downloaded</option>
</select> </select>
<input type="hidden" value="Go"> <%-- This hidden input might be redundant if data is sent via AJAX --%> <input type="hidden" value="Go">
</div> </div>
<table class="display" id="album_table"> <table class="display" id="album_table">
<thead> <thead>
<tr> <tr>
<th class="select-all-checkbox"><input type="checkbox" id="toggleAllAlbums" /></th> <%-- Added ID for easier targeting --%> <th id="select"><input type="checkbox" onClick="toggle(this)" /></th>
<th class="column-albumname">Album</th> <th id="albumname">Album</th>
<th class="column-artistname">Artist</th> <th id="artistname">Artist</th>
<th class="column-reldate">Date</th> <th id="reldate">Date</th>
<th class="column-type">Type</th> <th id="type">Type</th>
<th class="column-status">Status</th> <th id="status">Status</th>
<th class="column-have">Have</th> <th id="have">Have</th>
<th class="column-bitrate">Bitrate</th> <th id="bitrate">Bitrate</th>
<th class="column-albumformat">Format</th> <th id="albumformat">Format</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
%for album in albums: %for album in albums:
<% <%
# Assuming these values are now pre-calculated and available in the 'album' dict
# Removed all in-template database queries for performance.
if album['Status'] == 'Skipped': if album['Status'] == 'Skipped':
grade = 'Z' grade = 'Z'
elif album['Status'] == 'Wanted': elif album['Status'] == 'Wanted':
@@ -62,30 +58,45 @@
else: else:
grade = 'A' grade = 'A'
# Use the pre-calculated values from the album object myDB = db.DBConnection()
totaltracks_display = album.get('TotalTracks', '?') totaltracks = len(myDB.select('SELECT TrackTitle from tracks WHERE AlbumID=?', [album['AlbumID']]))
havetracks_display = album.get('HaveTracks', 0) havetracks = len(myDB.select('SELECT TrackTitle from tracks WHERE AlbumID=? AND Location IS NOT NULL', [album['AlbumID']])) + len(myDB.select('SELECT TrackTitle from have WHERE ArtistName like ? AND AlbumTitle LIKE ? AND Matched = "Failed"', [album['ArtistName'], album['AlbumTitle']]))
percent_display = album.get('PercentOwned', 0)
bitrate_display = album.get('BitrateDisplay', '') # e.g., '192 kbps' try:
albumformat_display = album.get('AlbumFormat', '') # e.g., 'MP3', 'FLAC', 'Mixed' percent = (havetracks*100.0)/totaltracks
if percent > 100:
percent = 100
except (ZeroDivisionError, TypeError):
percent = 0
totaltracks = '?'
avgbitrate = myDB.action("SELECT AVG(BitRate) FROM tracks WHERE AlbumID=?", [album['AlbumID']]).fetchone()[0]
if avgbitrate:
bitrate = str(int(avgbitrate)/1000) + ' kbps'
else:
bitrate = ''
albumformatcount = myDB.action("SELECT COUNT(DISTINCT Format) FROM tracks WHERE AlbumID=?", [album['AlbumID']]).fetchone()[0]
if albumformatcount == 1:
albumformat = myDB.action("SELECT DISTINCT Format FROM tracks WHERE AlbumID=?", [album['AlbumID']]).fetchone()[0]
elif albumformatcount > 1:
albumformat = 'Mixed'
else:
albumformat = ''
lossy_formats = [str.upper(fmt) for fmt in headphones.LOSSY_MEDIA_FORMATS]
%> %>
<tr class="grade${grade}"> <tr class="grade${grade}">
<td class="select-album-checkbox"><input type="checkbox" name="albumid_${album['AlbumID']}" value="${album['AlbumID']}" class="album-checkbox" /></td> <%-- Unique name and class for individual checkboxes --%> <td id="select"><input type="checkbox" name="${album['AlbumID']}" class="checkbox" /></td>
<td class="column-albumname"><a href="albumPage?AlbumID=${album['AlbumID']}">${album['AlbumTitle']}</a></td> <td id="albumname"><a href="albumPage?AlbumID=${album['AlbumID']}">${album['AlbumTitle']}</a></td>
<td class="column-artistname"><a href="artistPage?ArtistID=${album['ArtistID']}">${album['ArtistName']}</a></td> <td id="artistname"><a href="artistPage?ArtistID=${album['ArtistID']}">${album['ArtistName']}</a></td>
<td class="column-reldate">${album['ReleaseDate']}</td> <td id="reldate">${album['ReleaseDate']}</td>
<td class="column-type">${album['Type']}</td> <td id="type">${album['Type']}</td>
<td class="column-status">${album['Status']}</td> <td id="status">${album['Status']}</td>
<td class="column-have"> <td id="have"><span title="${percent}"><span><div class="progress-container"><div style="width:${percent}%"><div class="havetracks">${havetracks}/${totaltracks}</div></div></div></td>
<span title="${percent_display}"></span> <%-- Using percent_display for title attribute --%> <td id="bitrate">${bitrate}</td>
<div class="progress-container" role="progressbar" aria-valuenow="${percent_display | int}" aria-valuemin="0" aria-valuemax="100"> <td id="albumformat">${albumformat}</td>
<div style="width:${percent_display}%">
<div class="havetracks">${havetracks_display}/${totaltracks_display}</div>
</div>
</div>
</td>
<td class="column-bitrate">${bitrate_display}</td>
<td class="column-albumformat">${albumformat_display}</td>
</tr> </tr>
%endfor %endfor
</tbody> </tbody>
@@ -95,75 +106,28 @@
</%def> </%def>
<%def name="headIncludes()"> <%def name="headIncludes()">
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
<link rel="stylesheet" href="interfaces/default/css/data_table.css"> <link rel="stylesheet" href="interfaces/default/css/data_table.css">
<style>
/* Styles for progress bar, similar to index.html for consistency */
.progress-container {
background-color: #eee;
border-radius: 5px;
height: 15px; /* Adjust height as needed */
overflow: hidden;
position: relative;
width: 100%; /* Or a fixed width if preferred */
}
.progress-container > div {
background-color: #4CAF50; /* Green color for progress */
height: 100%;
border-radius: 5px;
text-align: center;
color: white;
font-size: 10px; /* Smaller font for percentage */
line-height: 15px; /* Vertically align text */
}
.havetracks {
padding: 0 5px; /* Add some padding around the text */
}
/* Styles for album status grades */
.gradeZ { /* Skipped */
background-color: #f2dede; /* Light red/pink */
}
.gradeX { /* Wanted */
background-color: #d9edf7; /* Light blue */
}
.gradeI { /* Ignored */
background-color: #fcf8e3; /* Light yellow */
}
.gradeC { /* Snatched */
background-color: #dff0d8; /* Light green */
}
.gradeA { /* Downloaded/Processed (default) */
/* No specific background or subtle light grey */
}
</style>
</%def> </%def>
<%def name="javascriptIncludes()"> <%def name="javascriptIncludes()">
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
<script src="js/libs/jquery.dataTables.min.js"></script> <script src="js/libs/jquery.dataTables.min.js"></script>
<script> <script>
// Encapsulate page-specific logic function initThisPage() {
var ManageAlbumsPage = ManageAlbumsPage || {};
ManageAlbumsPage.initDataTable = function() {
$('#album_table').dataTable({ $('#album_table').dataTable({
"bDestroy": true, "bDestroy": true,
"aoColumns": [ "aoColumns": [
null, // Checkbox column (not sortable) null,
null, // Album Name null,
null, // Artist Name null,
null, // Date null,
null, // Type null,
null, // Status null,
{ "sType": "title-numeric"}, // Have (uses title for numeric sort) { "sType": "title-numeric"},
null, // Bitrate null,
null // Format null
], ],
"aoColumnDefs": [ "aoColumnDefs": [
{ 'bSortable': false, 'aTargets': [ 0 ] } // Disable sorting for checkbox column { 'bSortable': false, 'aTargets': [ 0 ] }
], ],
"oLanguage": { "oLanguage": {
"sLengthMenu":"Show _MENU_ albums per page", "sLengthMenu":"Show _MENU_ albums per page",
@@ -172,83 +136,19 @@
"sInfoEmpty":"Showing 0 to 0 of 0 albums", "sInfoEmpty":"Showing 0 to 0 of 0 albums",
"sInfoFiltered":"(filtered from _MAX_ total albums)", "sInfoFiltered":"(filtered from _MAX_ total albums)",
"sSearch": ""}, "sSearch": ""},
"bPaginate": false, // All data loaded on one page "bPaginate": false,
"aaSorting": [[5, 'desc']], // Default sort by Status descending "aaSorting": [[5, 'desc']],
"fnDrawCallback": function (o) { "fnDrawCallback": function (o) {
// Jump to top of page // Jump to top of page
$('html,body').scrollTop(0); $('html,body').scrollTop(0);
} }
}); });
}; resetFilters("albums");
}
ManageAlbumsPage.initActions = function() {
// Event listener for the "Mark selected albums as" dropdown
$('#markAlbumActionSelect').on('change', function() {
var $this = $(this);
var actionValue = $this.val();
var selectedAlbumIds = [];
// Get all checked album checkboxes
$('.album-checkbox:checked').each(function() {
selectedAlbumIds.push($(this).val());
});
if (selectedAlbumIds.length === 0) {
// Display error if no albums are selected
if (typeof showMessage === 'function') { // Assuming showMessage is a global utility
showMessage($this.data('error') || "You didn't select any albums", 'error');
} else {
alert($this.data('error') || "You didn't select any albums");
}
// Reset select box to default
$this.val($('option:first', $this).val());
return; // Stop execution
}
// Construct URL with selected album IDs and action
var url = 'markAlbums?action=' + encodeURIComponent(actionValue);
$.each(selectedAlbumIds, function(index, id) {
url += '&albumid=' + encodeURIComponent(id); // Use a consistent param name if backend expects multiple
});
// Perform AJAX call
if (typeof doAjaxCall === 'function') {
doAjaxCall(url, $this, 'table', 'Albums marked as ' + actionValue + ' successfully!', 'Error marking albums.');
// After successful action, you might want to redraw the table or update rows
// $('#album_table').dataTable().fnDraw();
} else {
console.error("doAjaxCall function is not defined. Cannot mark albums asynchronously.");
}
// Reset select box to default after action
$this.val($('option:first', $this).val());
});
// Event listener for the "toggle all" checkbox
$('#toggleAllAlbums').on('click', function() {
$('.album-checkbox').prop('checked', this.checked);
});
// Optionally, reset individual checkboxes if header checkbox is unchecked (and vice-versa if needed)
$('.album-checkbox').on('click', function() {
if (!this.checked) {
$('#toggleAllAlbums').prop('checked', false);
} else {
// If all are checked, check the header checkbox
if ($('.album-checkbox:checked').length === $('.album-checkbox').length) {
$('#toggleAllAlbums').prop('checked', true);
}
}
});
};
$(document).ready(function() { $(document).ready(function() {
ManageAlbumsPage.initDataTable(); initThisPage();
ManageAlbumsPage.initActions();
// Assuming resetFilters is a global function from common.js
if (typeof resetFilters === 'function') {
resetFilters("albums");
}
}); });
</script> </script>
</%def> </%def>
+32 -142
View File
@@ -1,5 +1,7 @@
<%inherit file="base.html" /> <%inherit file="base.html" />
<%def name="headerIncludes()"> <%def name="headerIncludes()">
<div id="subhead_container"> <div id="subhead_container">
&nbsp; &nbsp;
@@ -13,10 +15,9 @@
<div id="manageheader" class="title"> <div id="manageheader" class="title">
<h1 class="clearfix"><i class="fa fa-music"></i> Manage Artists</h1> <h1 class="clearfix"><i class="fa fa-music"></i> Manage Artists</h1>
</div> </div>
<form action="markArtists" method="get" id="markArtistsForm"> <%-- Renamed ID to avoid conflict with markalbum div --%> <form action="markArtists" method="get" id="markArtists">
<div id="markartists_controls"> <%-- More descriptive ID for the controls div --%> <div id="markalbum">
<%-- Replaced inline onChange with an ID for JS handling --%> <select name="action" onChange="doAjaxCall('markArtists',$(this),'table',true);" data-error="You didn't select any artists">
<select name="action" id="markArtistActionSelect">
<option disabled="disabled" selected="selected">Choose...</option> <option disabled="disabled" selected="selected">Choose...</option>
<option value="pause">Pause</option> <option value="pause">Pause</option>
<option value="resume">Resume</option> <option value="resume">Resume</option>
@@ -24,17 +25,17 @@
<option value="delete">Delete</option> <option value="delete">Delete</option>
</select> </select>
selected artists selected artists
<input type="hidden" value="Go"> <%-- This hidden input might be redundant if data is sent via AJAX --%> <input type="hidden" value="Go">
</div> </div>
<table class="display" id="artist_table"> <table class="display" id="artist_table">
<thead> <thead>
<tr> <tr>
<th class="select-all-checkbox"><input type="checkbox" id="toggleAllArtists" /></th> <%-- Added ID for easier targeting --%> <th id="select"><input type="checkbox" onClick="toggle(this)" /></th>
<th class="column-albumart"></th> <%-- Changed ID to class for consistency and uniqueness --%> <th id="albumart"></th>
<th class="column-name">Artist Name</th> <th id="name">Artist Name</th>
<th class="column-status">Status</th> <th id="status">Status</th>
<th class="column-album">Latest Album</th> <th id="album">Latest Album</th>
<th class="column-lastupdated">Last Updated</th> <th id="lastupdated">Last Updated</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -47,7 +48,6 @@
else: else:
grade = 'Z' grade = 'Z'
# Python logic for display formatting is fine here, as it's not doing DB queries.
if artist['ReleaseDate'] and artist['LatestAlbum']: if artist['ReleaseDate'] and artist['LatestAlbum']:
releasedate = artist['ReleaseDate'] releasedate = artist['ReleaseDate']
albumdisplay = '<i>%s</i> (%s)' % (artist['LatestAlbum'], artist['ReleaseDate']) albumdisplay = '<i>%s</i> (%s)' % (artist['LatestAlbum'], artist['ReleaseDate'])
@@ -65,17 +65,12 @@
%> %>
<tr class="grade${grade}"> <tr class="grade${grade}">
<td class="select-artist-checkbox"><input type="checkbox" name="artistid_${artist['ArtistID']}" value="${artist['ArtistID']}" class="artist-checkbox" /></td> <%-- Unique name and class for individual checkboxes --%> <td id="select"><input type="checkbox" name="${artist['ArtistID']}" class="checkbox" /></td>
<td class="column-albumart"> <td id="albumart"><div id="artistImg"><img class="albumArt" id="${artist['ArtistID']}" src="artwork/thumbs/artist/${artist['ArtistID']}" height="50" width="50"></div></td>
<div class="artistImg-container"> <td id="name"><span title="${artist['ArtistSortName']}"></span><a href="artistPage?ArtistID=${artist['ArtistID']}">${artist['ArtistName']}</a></td>
<%-- Using data-src for lazy loading with jquery.unveil.min.js and native loading="lazy" --%> <td id="status">${artist['Status']}</td>
<img class="albumArt-thumb" alt="Album art for ${artist['ArtistName']}" data-src="artwork/thumbs/artist/${artist['ArtistID']}" loading="lazy" /> <td id="album"><span title="${releasedate}"></span><a href="albumPage?AlbumID=${artist['AlbumID']}">${albumdisplay}</a></td>
</div> <td id="lastupdated">${lastupdated}</td>
</td>
<td class="column-name"><span title="${artist['ArtistSortName']}"></span><a href="artistPage?ArtistID=${artist['ArtistID']}">${artist['ArtistName']}</a></td>
<td class="column-status">${artist['Status']}</td>
<td class="column-album"><span title="${releasedate}"></span><a href="albumPage?AlbumID=${artist['AlbumID']}">${albumdisplay}</a></td>
<td class="column-lastupdated">${lastupdated}</td>
</tr> </tr>
%endfor %endfor
</tbody> </tbody>
@@ -85,142 +80,37 @@
</%def> </%def>
<%def name="headIncludes()"> <%def name="headIncludes()">
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
<link rel="stylesheet" href="interfaces/default/css/data_table.css"> <link rel="stylesheet" href="interfaces/default/css/data_table.css">
<style>
/* Basic CSS for album art thumbnail, consistent with index.html */
.albumArt-thumb {
height: 50px;
width: 50px;
object-fit: cover; /* Ensures image covers the area without distortion */
vertical-align: middle;
}
/* Styles for artist status grades */
.gradeX { /* Paused */
background-color: #f2dede; /* Light red/pink */
}
.gradeC { /* Loading */
background-color: #dff0d8; /* Light green */
}
.gradeZ { /* Others (e.g., Active) */
/* No specific background or subtle light grey */
}
</style>
</%def> </%def>
<%def name="javascriptIncludes()"> <%def name="javascriptIncludes()">
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
<script src="js/libs/jquery.dataTables.min.js"></script> <script src="js/libs/jquery.dataTables.min.js"></script>
<script src="js/libs/jquery.unveil.min.js"></script> <%-- Added for lazy loading --%>
<script> <script>
// Encapsulate page-specific logic function initThisPage() {
var ManageArtistsPage = ManageArtistsPage || {};
ManageArtistsPage.initDataTable = function() {
$('#artist_table').dataTable({ $('#artist_table').dataTable({
"bDestroy": true, "bDestroy": true,
"aoColumns": [ "aoColumns": [
null, // Checkbox column (not sortable) null,
null, // Album art (not sortable by default) null,
{ "sType": "title-string"}, // Artist Name (uses title for sort) { "sType": "title-string"},
null, // Status null,
{ "sType": "title-string"}, // Latest Album (uses title for sort) { "sType": "title-string"},
null // Last Updated null
],
"aoColumnDefs": [
{ 'bSortable': false, 'aTargets': [ 0, 1 ] } // Disable sorting for checkbox and album art columns
], ],
"oLanguage": { "oLanguage": {
"sSearch" : "", "sSearch" : "",
"sEmptyTable": " " "sEmptyTable": " "
}, },
"bStateSave": true, // Retain table state across page loads "bStateSave": true,
"bPaginate": false, // All data loaded on one page "bPaginate": false
"fnDrawCallback": function (o) {
// Re-unveil images after each draw for lazy loading
$("img.albumArt-thumb").unveil();
}
}); });
}; resetFilters("artists");
}
ManageArtistsPage.initActions = function() {
// Event listener for the "Mark selected artists" dropdown
$('#markArtistActionSelect').on('change', function() {
var $this = $(this);
var actionValue = $this.val();
var selectedArtistIds = [];
// Get all checked artist checkboxes
$('.artist-checkbox:checked').each(function() {
selectedArtistIds.push($(this).val());
});
if (selectedArtistIds.length === 0) {
// Display error if no artists are selected
if (typeof showMessage === 'function') { // Assuming showMessage is a global utility
showMessage($this.data('error') || "You didn't select any artists", 'error');
} else {
alert($this.data('error') || "You didn't select any artists");
}
// Reset select box to default
$this.val($('option:first', $this).val());
return; // Stop execution
}
// Construct URL with selected artist IDs and action
var url = 'markArtists?action=' + encodeURIComponent(actionValue);
$.each(selectedArtistIds, function(index, id) {
url += '&artistid=' + encodeURIComponent(id); // Use a consistent param name if backend expects multiple
});
// Perform AJAX call
if (typeof doAjaxCall === 'function') {
doAjaxCall(url, $this, 'table', 'Artists marked as ' + actionValue + ' successfully!', 'Error marking artists.');
// After successful action, you might want to redraw the table or update rows
// $('#artist_table').dataTable().fnDraw();
} else {
console.error("doAjaxCall function is not defined. Cannot mark artists asynchronously.");
}
// Reset select box to default after action
$this.val($('option:first', $this).val());
});
// Event listener for the "toggle all" checkbox
$('#toggleAllArtists').on('click', function() {
$('.artist-checkbox').prop('checked', this.checked);
});
// Optionally, update "toggle all" checkbox based on individual checkboxes
$(document).on('click', '.artist-checkbox', function() {
if (!this.checked) {
$('#toggleAllArtists').prop('checked', false);
} else {
// If all are checked, check the header checkbox
if ($('.artist-checkbox:checked').length === $('.artist-checkbox').length) {
$('#toggleAllArtists').prop('checked', true);
}
}
});
};
$(document).ready(function() { $(document).ready(function() {
ManageArtistsPage.initDataTable(); initThisPage();
ManageArtistsPage.initActions();
// Assuming resetFilters is a global function from common.js
if (typeof resetFilters === 'function') {
resetFilters("artists");
}
}); });
$(window).load(function(){
// Call initFancybox if it's a global function that needs to run on window load initFancybox();
$(window).on('load', function(){
if (typeof initFancybox === 'function') {
initFancybox();
} else {
console.warn("initFancybox function is not defined.");
}
}); });
</script> </script>
</%def> </%def>
+54 -134
View File
@@ -1,9 +1,8 @@
<%inherit file="base.html" /> <%inherit file="base.html" />
<%! <%!
import headphones import headphones
# Removed direct DB imports/interactions here, as data should be pre-fetched server-side. from headphones import db, helpers
# from headphones import db, helpers myDB = db.DBConnection()
# myDB = db.DBConnection() # This should not be in the template
%> %>
<%def name="headerIncludes()"> <%def name="headerIncludes()">
@@ -21,82 +20,73 @@
<h1 class="clearfix"><i class="fa fa-music"></i> Manage Manually Matched Albums</h1> <h1 class="clearfix"><i class="fa fa-music"></i> Manage Manually Matched Albums</h1>
</div> </div>
<table class="display" id="manual_album_table"> <%-- Changed ID for clarity --%> <table class="display" id="artist_table">
<thead> <thead>
<tr> <tr>
<th class="column-artist">Local Artist</th> <%-- Changed ID to class --%> <th id="artist">Local Artist</th>
<th class="column-album">Local Album</th> <%-- Changed ID to class --%> <th id="album">Local Album</th>
<th class="column-status">Previous Action</th> <%-- Changed ID to class --%> <th id="status">Previous Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<% count_albums=0 %> <%-- Keeping count_albums for potential unique element IDs if still absolutely necessary, though aiming for generic approach --%> <% count_albums=0 %>
%for album in manualalbums: %for album in manualalbums:
<tr class="gradeZ"> <tr class="gradeZ">
<% <%
# These replacements should ideally be done server-side before passing to template,
# or in JS using encodeURIComponent, but kept here for minimal change.
old_artist_clean = album['ArtistName'].replace('&','%26').replace('+', '%2B').replace("'","%27") old_artist_clean = album['ArtistName'].replace('&','%26').replace('+', '%2B').replace("'","%27")
old_album_clean = album['AlbumTitle'].replace('&','%26').replace('+', '%2B').replace("'","%27") old_album_clean = album['AlbumTitle'].replace('&','%26').replace('+', '%2B').replace("'","%27")
%> %>
<td class="column-artist"> <td id="artist">${album['ArtistName']}<BR>
${album['ArtistName']}<BR> <button id="reset_artist${count_albums}" onClick="reset_Artist(this.id)">(<-) Reset Artist</button>
<%-- Use a common class and data attributes to pass info to generic dialog --%> <div id="reset_artist_dialog${count_albums}" title="Reset Artist" style="display:none">
<button type="button" class="reset-button" <table>
data-reset-type="artist" <tr><td>Are you sure you want to reset Local Artist: ${album['ArtistName']} to unmatched?</td></tr>
data-artist-name="${album['ArtistName']}" <tr><td align="right"><BR>
data-album-status="${album['AlbumStatus']}" %if album['AlbumStatus'] == "Ignored":
data-old-artist-clean="${old_artist_clean}"> <button href="javascript:void(0)" onclick="doAjaxCall('markManual?action=unignoreArtist&existing_artist=${old_artist_clean}', $(this), 'page');" data-success="Successfully reset ${album['ArtistName']} to unmatched">Reset Artist</button>
(&lt;-) Reset Artist %elif album['AlbumStatus'] == "Matched":
</button> <button href="javascript:void(0)" onclick="doAjaxCall('markManual?action=unmatchArtist&existing_artist=${old_artist_clean}', $(this), 'page');" data-success="Successfully restored ${album['ArtistName']} to unmatched">Reset Artist</button>
%endif
</td></tr>
</table>
</div>
</td> </td>
<td class="column-album"> <td id="album">${album['AlbumTitle']}<BR>
${album['AlbumTitle']}<BR> <button id="reset_album${count_albums}" onClick="reset_Album(this.id)">(<-) Reset Album</button>
<%-- Use a common class and data attributes to pass info to generic dialog --%> <div id="reset_album_dialog${count_albums}" title="Reset Album" style="display:none">
<button type="button" class="reset-button" <table>
data-reset-type="album" <tr><td>Are you sure you want to reset Local Album: ${album['AlbumTitle']} to unmatched?</td></tr>
data-artist-name="${album['ArtistName']}" <tr><td align="right"><BR>
data-album-title="${album['AlbumTitle']}" %if album['AlbumStatus'] == "Ignored":
data-album-status="${album['AlbumStatus']}" <button href="javascript:void(0)" onclick="doAjaxCall('markManual?action=unignoreAlbum&existing_artist=${old_artist_clean}&existing_album=${old_album_clean}', $(this), 'page');" data-success="Successfully reset ${album['AlbumTitle']} to unmatched">Reset Album</button>
data-old-artist-clean="${old_artist_clean}" %elif album['AlbumStatus'] == "Matched":
data-old-album-clean="${old_album_clean}"> <button href="javascript:void(0)" onclick="doAjaxCall('markManual?action=unmatchAlbum&existing_artist=${old_artist_clean}&existing_album=${old_album_clean}', $(this), 'page');" data-success="Successfully reset ${album['AlbumTitle']} to unmatched">Reset Album</button>
(&lt;-) Reset Album %endif
</button> </td></tr>
</table>
</div>
</td> </td>
<td class="column-status"> <td id="status">${album['AlbumStatus']}
${album['AlbumStatus']}
</td> </td>
</tr> </tr>
<% count_albums+=1 %> <% count_albums+=1 %>
%endfor %endfor
</tbody> </tbody>
</table> </table>
<%-- Generic Reset Confirmation Dialog (only one on the page) --%>
<div id="reset_dialog" title="Reset Confirmation" style="display:none">
<p class="dialog-message"></p>
<p class="dialog-actions" align="right"><BR>
<button type="button" class="confirm-reset-button"></button>
</p>
</div>
</div> </div>
</%def> </%def>
<%def name="headIncludes()"> <%def name="headIncludes()">
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
<link rel="stylesheet" href="interfaces/default/css/data_table.css"> <link rel="stylesheet" href="interfaces/default/css/data_table.css">
</%def> </%def>
<%def name="javascriptIncludes()"> <%def name="javascriptIncludes()">
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
<script src="js/libs/jquery.dataTables.min.js"></script> <script src="js/libs/jquery.dataTables.min.js"></script>
<script> <script>
// Encapsulate page-specific logic $(document).ready(function() {
var ManageManualPage = ManageManualPage || {}; $('#artist_table').dataTable({
ManageManualPage.initDataTable = function() {
$('#manual_album_table').dataTable({ <%-- Use the updated ID --%>
"bStateSave": true, "bStateSave": true,
"bPaginate": true, "bPaginate": true,
"oLanguage": { "oLanguage": {
@@ -106,98 +96,28 @@
"sInfoEmpty":"Showing 0 to 0 of 0 albums", "sInfoEmpty":"Showing 0 to 0 of 0 albums",
"sInfoFiltered":"(filtered from _MAX_ total albums)", "sInfoFiltered":"(filtered from _MAX_ total albums)",
"sEmptyTable": " ", "sEmptyTable": " ",
}, },
"sPaginationType": "full_numbers", "sPaginationType": "full_numbers",
"fnDrawCallback": function (o) { "fnDrawCallback": function (o) {
// Jump to top of page // Jump to top of page
$('html,body').scrollTop(0); $('html,body').scrollTop(0);
} }
}); });
};
ManageManualPage.initDialogs = function() { initActions();
// Initialize the generic reset dialog once
$('#reset_dialog').dialog({
autoOpen: false,
modal: true,
resizable: false,
width: 500,
height: 'auto',
buttons: {
"Cancel": function() {
$(this).dialog('close');
}
}
});
};
ManageManualPage.initActions = function() {
// Event delegation for all reset buttons
$(document).on('click', '.reset-button', function() {
var $button = $(this);
var resetType = $button.data('reset-type'); // 'artist' or 'album'
var artistName = $button.data('artist-name');
var albumTitle = $button.data('album-title');
var albumStatus = $button.data('album-status');
var oldArtistClean = $button.data('old-artist-clean');
var oldAlbumClean = $button.data('old-album-clean');
var $dialog = $('#reset_dialog');
var $dialogMessage = $dialog.find('.dialog-message');
var $confirmButton = $dialog.find('.confirm-reset-button');
var message = "";
var actionUrl = "";
var successMessage = "";
if (resetType === 'artist') {
message = "Are you sure you want to reset Local Artist: " + artistName + " to unmatched?";
if (albumStatus === "Ignored") {
actionUrl = 'markManual?action=unignoreArtist&existing_artist=' + oldArtistClean;
successMessage = "Successfully reset " + artistName + " to unmatched";
} else if (albumStatus === "Matched") {
actionUrl = 'markManual?action=unmatchArtist&existing_artist=' + oldArtistClean;
successMessage = "Successfully restored " + artistName + " to unmatched";
}
$confirmButton.text('Reset Artist');
} else if (resetType === 'album') {
message = "Are you sure you want to reset Local Album: " + albumTitle + " to unmatched?";
if (albumStatus === "Ignored") {
actionUrl = 'markManual?action=unignoreAlbum&existing_artist=' + oldArtistClean + '&existing_album=' + oldAlbumClean;
successMessage = "Successfully reset " + albumTitle + " to unmatched";
} else if (albumStatus === "Matched") {
actionUrl = 'markManual?action=unmatchAlbum&existing_artist=' + oldArtistClean + '&existing_album=' + oldAlbumClean;
successMessage = "Successfully reset " + albumTitle + " to unmatched";
}
$confirmButton.text('Reset Album');
}
$dialogMessage.text(message);
// Unbind previous click handler and bind new one
$confirmButton.off('click').on('click', function() {
if (typeof doAjaxCall === 'function') {
doAjaxCall(actionUrl, $button, 'page', successMessage);
} else {
console.error("doAjaxCall function is not defined. Cannot perform reset action.");
}
$dialog.dialog('close');
});
$dialog.dialog('open');
});
// Assuming initActions from common.js is called globally
if (typeof initActions === 'function') {
initActions();
}
};
$(document).ready(function() {
ManageManualPage.initDataTable();
ManageManualPage.initDialogs();
ManageManualPage.initActions();
}); });
function reset_Artist(clicked_id) {
n=clicked_id.replace("reset_artist","");
$("#reset_artist_dialog"+n).dialog();
return false;
}
function reset_Album(clicked_id) {
n=clicked_id.replace("reset_album","");
$("#reset_album_dialog"+n).dialog();
return false;
}
</script> </script>
</%def> </%def>
+13 -105
View File
@@ -6,8 +6,7 @@
<%def name="headerIncludes()"> <%def name="headerIncludes()">
<div id="subhead_container"> <div id="subhead_container">
<div id="subhead_menu"> <div id="subhead_menu">
<%-- Changed inline onclick to a class and data attributes for JS handling --%> <a id="menu_link_scan" onclick="doAjaxCall('musicScan?path=${headphones.CONFIG.MUSIC_DIR}&redirect=manageNew',$(this))" data-success="Music library is getting scanned">Scan Music Library</a>
<a id="menu_link_scan" class="ajax-link" data-action="musicScan" data-path="${headphones.CONFIG.MUSIC_DIR}" data-redirect="manageNew" data-success="Music library is getting scanned" href="#">Scan Music Library</a>
</div> </div>
</div> </div>
<a href="manage" class="back">&laquo; Back to manage overview</a> <a href="manage" class="back">&laquo; Back to manage overview</a>
@@ -19,28 +18,26 @@
<div id="manageheader" class="title"> <div id="manageheader" class="title">
<h1 class="clearfix"><i class="fa fa-music"></i> Manage New Artists</h1> <h1 class="clearfix"><i class="fa fa-music"></i> Manage New Artists</h1>
</div> </div>
<form action="addArtists" method="get" id="addArtistsForm"> <%-- Added ID to the form --%> <form action="addArtists" method="get">
<div id="new_artists_controls"> <%-- Unique and descriptive ID --%> <div id="markalbum">
<select name="action" id="newArtistActionSelect"> <select name="action">
<option value="add">(+) ADD Selected Artists</option> <option value="add">(+) ADD Selected Artists</option>
<option value="ignore">(-) IGNORE Selected Artists</option> <option value="ignore">(-) IGNORE Selected Artists</option>
</select> </select>
<%-- Changed input type="submit" to button with class for AJAX handling --%> <input type="submit" value="Go">
<button type="button" id="submitNewArtistAction" class="ajax-submit-button" data-form-id="addArtistsForm" data-action="addArtists" data-error="You didn't select any artists">Go</button>
</div> </div>
<table class="display" id="artist_table"> <table class="display" id="artist_table">
<thead> <thead>
<tr> <tr>
<th class="select-all-checkbox"><input type="checkbox" id="toggleAllNewArtists" /></th> <%-- Added ID for easier targeting --%> <th id="select"><input type="checkbox" onClick="toggle(this)" /></th>
<th class="column-name">Artist Name</th> <%-- Changed ID to class --%> <th id="name">Artist Name</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
%for artist in newartists: %for artist in newartists:
<tr class="gradeZ"> <tr class="gradeZ">
<%-- Changed name attribute to a consistent 'artist_ids' and value to ArtistID for robust processing --%> <td id="select"><input type="checkbox" name="${artist['ArtistName']}" class="checkbox" /></td>
<td class="select-artist-checkbox"><input type="checkbox" name="artist_ids" value="${artist['ArtistID']}" class="new-artist-checkbox" /></td> <td id="name">${artist['ArtistName']}</a></td>
<td class="column-name">${artist['ArtistName']}</a></td>
</tr> </tr>
%endfor %endfor
</tbody> </tbody>
@@ -50,111 +47,22 @@
</%def> </%def>
<%def name="headIncludes()"> <%def name="headIncludes()">
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
<link rel="stylesheet" href="interfaces/default/css/data_table.css"> <link rel="stylesheet" href="interfaces/default/css/data_table.css">
</%def> </%def>
<%def name="javascriptIncludes()"> <%def name="javascriptIncludes()">
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
<script src="js/libs/jquery.dataTables.min.js"></script> <script src="js/libs/jquery.dataTables.min.js"></script>
<script> <script>
// Encapsulate page-specific logic $(document).ready(function() {
var ManageNewArtistsPage = ManageNewArtistsPage || {};
ManageNewArtistsPage.initDataTable = function() {
$('#artist_table').dataTable({ $('#artist_table').dataTable({
"aaSorting": [[1, 'asc']], // Sort by Artist Name ascending "aaSorting": [[1, 'asc']],
"bStateSave": false, "bStateSave": false,
"bPaginate": false, "bPaginate": false,
"oLanguage": { "oLanguage": {
"sSearch" : "", "sSearch" : ""},
"sEmptyTable": "No new artist information available"
},
"fnDrawCallback": function (o) {
$('html,body').scrollTop(0); // Jump to top of page on draw
}
}); });
};
ManageNewArtistsPage.initActions = function() { initActions();
// Event listener for the "Scan Music Library" link in the header
$(document).on('click', '#menu_link_scan', function(e) {
e.preventDefault();
var $this = $(this);
var path = $this.data('path');
var redirect = $this.data('redirect');
var successMsg = $this.data('success');
var url = $this.data('action') + '?path=' + encodeURIComponent(path) + '&redirect=' + encodeURIComponent(redirect);
if (typeof doAjaxCall === 'function') {
doAjaxCall(url, $this, null, successMsg); // 'null' for context if not affecting a specific tab/table
} else {
console.error("doAjaxCall function is not defined. Cannot scan music library.");
}
});
// Event listener for the "Go" button to add/ignore artists
$('#submitNewArtistAction').on('click', function(e) {
e.preventDefault();
var $this = $(this);
var action = $('#newArtistActionSelect').val();
var selectedArtistIds = [];
// Get all checked new artist checkboxes
$('.new-artist-checkbox:checked').each(function() {
selectedArtistIds.push($(this).val());
});
if (selectedArtistIds.length === 0) {
if (typeof showMessage === 'function') {
showMessage($this.data('error') || "You didn't select any artists", 'error');
} else {
alert($this.data('error') || "You didn't select any artists");
}
return;
}
var url = $this.data('action') + '?action=' + encodeURIComponent(action);
$.each(selectedArtistIds, function(index, id) {
url += '&artist_ids=' + encodeURIComponent(id); // Append selected artist IDs
});
var successMsg = 'Artists successfully ' + (action === 'add' ? 'added' : 'ignored');
var errorMsg = 'Error performing action on artists.';
if (typeof doAjaxCall === 'function') {
doAjaxCall(url, $this, 'table', successMsg, errorMsg);
// Optionally, refresh the table or remove processed rows after successful AJAX call
} else {
console.error("doAjaxCall function is not defined. Cannot add/ignore artists.");
}
});
// Event listener for the "toggle all" checkbox
$('#toggleAllNewArtists').on('click', function() {
$('.new-artist-checkbox').prop('checked', this.checked);
});
// Update "toggle all" checkbox based on individual checkboxes
$(document).on('click', '.new-artist-checkbox', function() {
if (!this.checked) {
$('#toggleAllNewArtists').prop('checked', false);
} else {
if ($('.new-artist-checkbox:checked').length === $('.new-artist-checkbox').length) {
$('#toggleAllNewArtists').prop('checked', true);
}
}
});
// Assuming initActions from common.js is called globally
if (typeof initActions === 'function') {
initActions();
}
};
$(document).ready(function() {
ManageNewArtistsPage.initDataTable();
ManageNewArtistsPage.initActions();
}); });
</script> </script>
</%def> </%def>
+192 -278
View File
@@ -1,20 +1,16 @@
<%inherit file="base.html" /> <%inherit file="base.html" />
<%! <%!
import headphones import headphones
# Removed direct DB imports and queries from template. import json
# These operations (fetching artists and creating json_artists) from headphones import db, helpers
# should be performed in the Python view/controller and passed myDB = db.DBConnection()
# to the template as part of its context. artist_json = {}
# import json counter = 0
# from headphones import db, helpers artist_list = myDB.action("SELECT ArtistName from artists ORDER BY ArtistName COLLATE NOCASE")
# myDB = db.DBConnection() for artist in artist_list:
# artist_json = {} artist_json[counter] = artist['ArtistName']
# counter = 0 counter+=1
# artist_list = myDB.action("SELECT ArtistName from artists ORDER BY ArtistName COLLATE NOCASE") json_artists = json.dumps(artist_json)
# for artist in artist_list:
# artist_json[counter] = artist['ArtistName']
# counter+=1
# json_artists = json.dumps(artist_json)
%> %>
<%def name="headerIncludes()"> <%def name="headerIncludes()">
@@ -33,109 +29,97 @@
<h1 class="clearfix"><i class="fa fa-music"></i> Manage Unmatched Albums</h1> <h1 class="clearfix"><i class="fa fa-music"></i> Manage Unmatched Albums</h1>
</div> </div>
<table class="display" id="unmatched_album_table"> <%-- Changed ID for clarity --%> <table class="display" id="artist_table">
<thead> <thead>
<tr> <tr>
<th class="column-artist">Local Artist</th> <th id="artist">Local Artist</th>
<th class="column-album">Local Album</th> <th id="album">Local Album</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<% count_albums=0 %> <%-- Still useful for unique IDs if needed, but aiming for generic dialogs --%> <% count_albums=0 %>
%for album in unmatchedalbums: %for album in unmatchedalbums:
<tr class="gradeZ"> <tr class="gradeZ">
<% <%
# Pre-escape for direct use in data-attributes where JS string is needed. old_artist_clean = album['ArtistName'].replace('&','%26').replace("'","%27")
# URL encoding will happen in JS with encodeURIComponent. old_album_clean = album['AlbumTitle'].replace('&','%26').replace("'","%27")
old_artist_js_str = album['ArtistName'].replace("'","\\'").replace('"','&quot;') old_artist_js = album['ArtistName'].replace("'","\\'").replace('"','\\"')
old_album_js_str = album['AlbumTitle'].replace("'","\\'").replace('"','&quot;') old_album_js = album['AlbumTitle'].replace("'","\\'").replace('"','\\"')
%> %>
<td class="column-artist"> <td id="artist">${album['ArtistName']}<BR>
${album['ArtistName']}<BR> <button id="ignore_artists${count_albums}" onClick="ignore_Artist(this.id)">(-) Ignore Artist</button>
<%-- Data attributes to pass context to JS --%> <div id="ignore_artist_dialog${count_albums}" title="Ignore Artist" style="display:none">
<button type="button" class="action-button ignore-artist-button" <table>
data-artist-name="${album['ArtistName']}" <tr><td>Are you sure you want to ignore Local Artist: ${album['ArtistName']} from future matching?</td></tr>
data-old-artist-js="${old_artist_js_str}"> <tr><td align="right"><BR>
(-) Ignore Artist <button href="javascript:void(0)" onclick="doAjaxCall('markUnmatched?action=ignoreArtist&existing_artist=${old_artist_clean}', $(this), 'page');" data-success="Successfully ignored ${album['ArtistName']} from future matching">Ignore Artist</button>
</button> </td></tr>
</table>
</div>
<button id="match_artists${count_albums}" onClick="load_Artist(this.id)">(->) Match Artist</button>
<div id="artist_dialog${count_albums}" title="Match Artist" style="display:none">
<table width=400>
<tr><td>Local Artist:</td><td>${album['ArtistName']}</td></tr>
<tr><td>Match Artist</td><td>
<select id="artist_options${count_albums}" name="new_artist">
</select>
</td></tr>
<tr><td></td><td align="right"><BR>
<button href="javascript:void(0)" onclick="artist_matcher(${count_albums}, '${old_artist_js}')">Match Artist</button>
</td></tr>
</table>
</div>
<button type="button" class="action-button match-artist-button"
data-artist-name="${album['ArtistName']}"
data-old-artist-js="${old_artist_js_str}">
(->) Match Artist
</button>
</td> </td>
<td class="column-album"> <td id="album">${album['AlbumTitle']}<BR>
${album['AlbumTitle']}<BR> <button id="ignore_albums${count_albums}" onClick="ignore_Album(this.id)">(-) Ignore Album</button>
<button type="button" class="action-button ignore-album-button" <div id="ignore_album_dialog${count_albums}" title="Ignore Album" style="display:none">
data-artist-name="${album['ArtistName']}" <table>
data-album-title="${album['AlbumTitle']}" <tr><td>Are you sure you want to ignore Local Album: ${album['AlbumTitle']} from future matching?</td></tr>
data-old-artist-js="${old_artist_js_str}" <tr><td align="right"><BR>
data-old-album-js="${old_album_js_str}"> <button href="javascript:void(0)" onclick="doAjaxCall('markUnmatched?action=ignoreAlbum&existing_artist=${old_artist_clean}&existing_album=${old_album_clean}', $(this), 'page');" data-success="Successfully ignored ${album['AlbumTitle']} from future matching">Ignore Album</button>
(-) Ignore Album </td></tr>
</button> </table>
</div>
<button id="match_albums${count_albums}" onClick="load_AlbumArtist(this.id)">(->) Match Album</button>
<div id="album_dialog${count_albums}" title="Match Album" style="display:none">
<table width=650>
<tr><td>Local Artist:</td><td>${album['ArtistName']}</td></tr>
<tr><td>Local Album:</td><td>${album['AlbumTitle']}</td></tr>
<tr><td>Match Artist</td><td>
<select id="album_artist_options${count_albums}" name="new_artist">
</select>
</td></tr>
<tr><td>Match Album</td><td>
<select id="album_options${count_albums}" name="new_album">
</select>
</td></tr>
<tr><td></td><td align="right"><BR>
<button href="javascript:void(0)" onclick="album_matcher(${count_albums}, '${old_artist_js}', '${old_album_js}')">Match Album</button>
</td></tr>
</table>
</div>
<button type="button" class="action-button match-album-button"
data-artist-name="${album['ArtistName']}"
data-album-title="${album['AlbumTitle']}"
data-old-artist-js="${old_artist_js_str}"
data-old-album-js="${old_album_js_str}">
(->) Match Album
</button>
</td> </td>
</tr> </tr>
<% count_albums+=1 %> <% count_albums+=1 %>
%endfor %endfor
</tbody> </tbody>
</table> </table>
<%-- Generic Ignore Confirmation Dialog --%>
<div id="ignore_dialog" title="Confirm Ignore" style="display:none">
<p class="dialog-message"></p>
<p class="dialog-actions" align="right"><BR>
<button type="button" class="confirm-ignore-button"></button>
</p>
</div>
<%-- Generic Match Dialog --%>
<div id="match_dialog" title="Match Album" style="display:none">
<p><strong>Local Artist:</strong> <span id="local-artist-display"></span></p>
<p><strong>Local Album:</strong> <span id="local-album-display"></span></p>
<div id="artist-match-section">
<p>Match Artist:</p>
<select id="match-artist-options" name="new_artist"></select>
</div>
<div id="album-match-section">
<p>Match Album:</p>
<select id="match-album-options" name="new_album"></select>
</div>
<p class="dialog-actions" align="right"><BR>
<button type="button" class="confirm-match-button"></button>
</p>
</div>
</div> </div>
</%def> </%def>
<%def name="headIncludes()"> <%def name="headIncludes()">
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
<link rel="stylesheet" href="interfaces/default/css/data_table.css"> <link rel="stylesheet" href="interfaces/default/css/data_table.css">
</%def> </%def>
<%def name="javascriptIncludes()"> <%def name="javascriptIncludes()">
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
<script src="js/libs/jquery.dataTables.min.js"></script> <script src="js/libs/jquery.dataTables.min.js"></script>
<script> <script>
// Encapsulate page-specific logic $(document).ready(function() {
var ManageUnmatchedPage = ManageUnmatchedPage || {}; $('#artist_table').dataTable({
ManageUnmatchedPage.jsonArtists = ${json_artists | n, unicode}; // Assuming json_artists is passed from backend
ManageUnmatchedPage.initDataTable = function() {
$('#unmatched_album_table').dataTable({ <%-- Use the updated ID --%>
"bStateSave": true, "bStateSave": true,
"bPaginate": true, "bPaginate": true,
"oLanguage": { "oLanguage": {
@@ -145,203 +129,133 @@
"sInfoEmpty":"Showing 0 to 0 of 0 albums", "sInfoEmpty":"Showing 0 to 0 of 0 albums",
"sInfoFiltered":"(filtered from _MAX_ total albums)", "sInfoFiltered":"(filtered from _MAX_ total albums)",
"sEmptyTable": " ", "sEmptyTable": " ",
}, },
"sPaginationType": "full_numbers", "sPaginationType": "full_numbers",
"fnDrawCallback": function (o) { "fnDrawCallback": function (o) {
// Jump to top of page // Jump to top of page
$('html,body').scrollTop(0); $('html,body').scrollTop(0);
} }
}); });
};
ManageUnmatchedPage.initDialogs = function() { initActions();
// Initialize the generic ignore dialog
$('#ignore_dialog').dialog({
autoOpen: false,
modal: true,
resizable: false,
width: 500,
height: 'auto',
buttons: {
"Cancel": function() {
$(this).dialog('close');
}
}
});
// Initialize the generic match dialog
$('#match_dialog').dialog({
autoOpen: false,
modal: true,
resizable: false,
width: 700,
height: 'auto',
buttons: {
"Cancel": function() {
$(this).dialog('close');
}
}
});
};
ManageUnmatchedPage.initActions = function() {
// --- Ignore Artist/Album Logic ---
$(document).on('click', '.ignore-artist-button, .ignore-album-button', function() {
var $button = $(this);
var isArtistIgnore = $button.hasClass('ignore-artist-button');
var artistName = $button.data('artist-name');
var albumTitle = $button.data('album-title');
var oldArtistJs = $button.data('old-artist-js'); // JS escaped, needs URI encoding for URL
var oldAlbumJs = $button.data('old-album-js'); // JS escaped, needs URI encoding for URL
var $dialog = $('#ignore_dialog');
var $dialogMessage = $dialog.find('.dialog-message');
var $confirmButton = $dialog.find('.confirm-ignore-button');
var message = "";
var actionUrl = "";
var successMessage = "";
if (isArtistIgnore) {
message = "Are you sure you want to ignore Local Artist: " + artistName + " from future matching?";
actionUrl = 'markUnmatched?action=ignoreArtist&existing_artist=' + encodeURIComponent(oldArtistJs);
successMessage = "Successfully ignored " + artistName + " from future matching";
$confirmButton.text('Ignore Artist');
} else { // ignore-album-button
message = "Are you sure you want to ignore Local Album: " + albumTitle + " from future matching?";
actionUrl = 'markUnmatched?action=ignoreAlbum&existing_artist=' + encodeURIComponent(oldArtistJs) + '&existing_album=' + encodeURIComponent(oldAlbumJs);
successMessage = "Successfully ignored " + albumTitle + " from future matching";
$confirmButton.text('Ignore Album');
}
$dialogMessage.text(message);
$confirmButton.off('click').on('click', function() {
if (typeof doAjaxCall === 'function') {
doAjaxCall(actionUrl, $button, 'page', successMessage);
} else {
console.error("doAjaxCall function is not defined. Cannot perform ignore action.");
}
$dialog.dialog('close');
});
$dialog.dialog('open');
});
// --- Match Artist/Album Logic ---
$(document).on('click', '.match-artist-button, .match-album-button', function() {
var $button = $(this);
var isArtistMatch = $button.hasClass('match-artist-button');
var artistName = $button.data('artist-name');
var albumTitle = $button.data('album-title');
var oldArtistJs = $button.data('old-artist-js');
var oldAlbumJs = $button.data('old-album-js');
var $dialog = $('#match_dialog');
var $localArtistDisplay = $('#local-artist-display');
var $localAlbumDisplay = $('#local-album-display');
var $artistMatchSection = $('#artist-match-section');
var $albumMatchSection = $('#album-match-section');
var $matchArtistOptions = $('#match-artist-options');
var $matchAlbumOptions = $('#match-album-options');
var $confirmButton = $dialog.find('.confirm-match-button');
// Reset dialog state
$localArtistDisplay.text(artistName);
$matchArtistOptions.empty();
$matchAlbumOptions.empty();
$confirmButton.off('click'); // Clear previous click handlers
// Populate artist options
$.each(ManageUnmatchedPage.jsonArtists, function(key, value) {
$matchArtistOptions.append($("<option/>", {
value: value,
text: value
}));
});
if (isArtistMatch) {
$localAlbumDisplay.closest('p').hide(); // Hide local album row
$albumMatchSection.hide(); // Hide match album section
$localArtistDisplay.text(artistName);
$artistMatchSection.show(); // Show match artist section
$confirmButton.text('Match Artist');
$confirmButton.on('click', function() {
var newArtist = $matchArtistOptions.val();
var actionUrl = 'markUnmatched?action=matchArtist&existing_artist=' + encodeURIComponent(oldArtistJs) + '&new_artist=' + encodeURIComponent(newArtist);
var successMessage = 'Successfully matched ' + artistName + ' with ' + newArtist;
if (typeof doAjaxCall === 'function') {
doAjaxCall(actionUrl, $button, 'page', successMessage);
} else {
console.error("doAjaxCall function is not defined. Cannot match artist.");
}
$dialog.dialog('close');
});
} else { // match-album-button
$localAlbumDisplay.closest('p').show(); // Show local album row
$albumMatchSection.show(); // Show match album section
$localAlbumDisplay.text(albumTitle);
$artistMatchSection.show(); // Show match artist section (for selecting matched album's artist)
$confirmButton.text('Match Album');
// Load albums for selected artist initially
ManageUnmatchedPage.loadAlbumsForArtist($matchArtistOptions.val(), $matchAlbumOptions);
// Handle artist selection change
$matchArtistOptions.off('change').on('change', function() {
ManageUnmatchedPage.loadAlbumsForArtist($(this).val(), $matchAlbumOptions);
});
$confirmButton.on('click', function() {
var newArtist = $matchArtistOptions.val();
var newAlbum = $matchAlbumOptions.val();
var actionUrl = 'markUnmatched?action=matchAlbum&existing_artist=' + encodeURIComponent(oldArtistJs) + '&new_artist=' + encodeURIComponent(newArtist) + '&existing_album=' + encodeURIComponent(oldAlbumJs) + '&new_album=' + encodeURIComponent(newAlbum);
var successMessage = 'Successfully matched ' + albumTitle + ' with ' + newAlbum + ' by ' + newArtist;
if (typeof doAjaxCall === 'function') {
doAjaxCall(actionUrl, $button, 'page', successMessage);
} else {
console.error("doAjaxCall function is not defined. Cannot match album.");
}
$dialog.dialog('close');
});
}
$dialog.dialog('open');
});
// Helper function to load albums for a given artist
ManageUnmatchedPage.loadAlbumsForArtist = function(artistName, $targetSelect) {
$targetSelect.empty().append($("<option/>", { value: "", text: "Loading albums..." })); // Add loading message
var cleanArtistName = encodeURIComponent(artistName);
$.getJSON("getAlbumsByArtist_json?artist=" + cleanArtistName, function(data) {
$targetSelect.empty();
if (Object.keys(data).length > 0) {
$.each(data, function(key, value) {
$targetSelect.append($("<option/>", {
value: value,
text: value
}));
});
} else {
$targetSelect.append($("<option/>", { value: "", text: "No albums found" }));
}
}).fail(function() {
$targetSelect.empty().append($("<option/>", { value: "", text: "Error loading albums" }));
});
};
// Assuming initActions from common.js is called globally
if (typeof initActions === 'function') {
initActions();
}
};
$(document).ready(function() {
ManageUnmatchedPage.initDataTable();
ManageUnmatchedPage.initDialogs();
ManageUnmatchedPage.initActions();
}); });
function ignore_Artist(clicked_id) {
n=clicked_id.replace("ignore_artists","");
$("#ignore_artist_dialog"+n).dialog();
return false;
}
function ignore_Album(clicked_id) {
n=clicked_id.replace("ignore_albums","");
$("#ignore_album_dialog"+n).dialog();
return false;
}
function load_Artist(clicked_id) {
n=clicked_id.replace("match_artists","");
var d = $("#artist_dialog"+n).dialog();
d.dialog("option", "width", 450);
d.dialog("option", "position", "center");
$('#artist_options'+n).html('');
$.each(${json_artists}, function(key, value) {
$('#artist_options'+n).append($("<option/>", {
value: value,
text: value
}));
});
change_artist(n)
return false;
}
function change_artist(n) {
selected_artist = $("#artist_options"+n).find("option:selected").text();
selected_artist_clean = selected_artist.replace('&', '%26').replace('+', '%2B');
$("#artist_options"+n).change(function() {
selected_artist = $("#artist_options"+n).find("option:selected").text();
selected_artist_clean = selected_artist.replace('&', '%26').replace('+', '%2B');
});
}
function artist_matcher(n, existing_artist) {
var existing_artist = existing_artist.toString();
var existing_artist_clean = existing_artist.replace('&', '%26').replace('+', '%2B');
$('#match_artists'+n).attr('data-success', 'Successfully matched '+existing_artist+' with '+selected_artist);
doAjaxCall('markUnmatched?action=matchArtist&existing_artist='+existing_artist_clean+'&new_artist='+selected_artist_clean, $('#match_artists'+n), 'page');
}
function load_AlbumArtist(clicked_id) {
n=clicked_id.replace("match_albums","");
var d = $("#album_dialog"+n).dialog();
d.dialog("option", "width", 700);
d.dialog("option", "position", "center");
$('#album_artist_options'+n).html('');
$.each(${json_artists}, function(key, value) {
$('#album_artist_options'+n).append($("<option/>", {
value: value,
text: value
}));
});
load_json_albums(n)
return false;
}
function load_json_albums(n) {
selected_album_artist = $("#album_artist_options"+n).find("option:selected").text();
selected_album_artist_clean = selected_album_artist.replace('&', '%26').replace('+', '%2B');
$('#album_options'+n).html('')
$.getJSON("getAlbumsByArtist_json?artist="+selected_album_artist_clean, function( data ) {
$.each( data, function( key, value ) {
$('#album_options'+n).append($("<option/>", {
value: value,
text: value
}));
});
selected_album= $("#album_options"+n).find("option:selected").text();
selected_album_clean = selected_album.replace('&', '%26').replace('+', '%2B');
});
change_json_albums(n)
change_album(n)
}
function change_json_albums(n) {
$("#album_artist_options"+n).change(function(){
selected_album_artist = $("#album_artist_options"+n).find("option:selected").text();
selected_album_artist_clean = selected_album_artist.replace('&', '%26').replace('+', '%2B');
$('#album_options'+n).html('')
$.getJSON("getAlbumsByArtist_json?artist="+selected_album_artist_clean, function( data ) {
$.each( data, function( key, value ) {
$('#album_options'+n).append($("<option/>", {
value: value,
text: value
}));
});
selected_album= $("#album_options"+n).find("option:selected").text();
selected_album_clean = selected_album.replace('&', '%26').replace('+', '%2B');
});
change_album(n)
});
}
function change_album(n) {
$("#album_options"+n).change(function() {
selected_album= $("#album_options"+n).find("option:selected").text();
selected_album_clean = selected_album.replace('&', '%26').replace('+', '%2B');
});
}
function album_matcher(n, existing_artist, existing_album) {
var existing_artist = existing_artist.toString();
var existing_artist_clean = existing_artist.replace('&', '%26').replace('+', '%2B');
var existing_album = existing_album.toString();
var existing_album_clean = existing_album.replace('&', '%26').replace('+', '%2B');
$('#match_albums'+n).attr('data-success', 'Successfully matched '+existing_album+' with '+selected_album);
doAjaxCall('markUnmatched?action=matchAlbum&existing_artist='+existing_artist_clean+'&new_artist='+selected_album_artist_clean+'&existing_album='+existing_album_clean+'&new_album='+selected_album_clean, $('#match_albums'+n), 'page');
}
</script> </script>
</%def> </%def>
+71 -158
View File
@@ -8,24 +8,24 @@
<table class="display" id="searchresults_table"> <table class="display" id="searchresults_table">
<thead> <thead>
<tr> <tr>
<th class="column-albumart"></th> <%-- Changed ID to class --%> <th id="albumart"></th>
%if type == 'album': %if type == 'album':
<th class="column-albumname">Album Name</th> <th id="albumname">Album Name</th>
<th class="column-artistname-small">Artist Name</th> <th id="artistnamesmall">Artist Name</th>
<th class="column-format">Format</th> <th id="format">Format</th>
<th class="column-tracks">Tracks</th> <th id="tracks">Tracks</th>
<th class="column-reldate">Date</th> <th id="reldate">Date</th>
<th class="column-score-small">Score</th> <th id="scoresmall">Score</th>
<th class="column-mbrelid" style="display:none;"</th> <%-- Move display:none to CSS if always hidden --%> <th id="mbrelid" style="display:none;"</th>
%elif type == 'artist': %elif type == 'artist':
<th class="column-artistname">Artist Name</th> <th id="artistname">Artist Name</th>
<th class="column-score">Score</th> <th id="score">Score</th>
%else: %else:
<th class="column-seriesname">Series Name</th> <th id="seriesname">Series Name</th>
<th class="column-type">Type</th> <th id="type">Type</th>
<th class="column-score">Score</th> <th id="score">Score</th>
%endif %endif
<th class="column-mb"></th> <th id="mb"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -39,55 +39,34 @@
if type == 'album': if type == 'album':
albuminfo = 'Type: ' + result['rgtype'] + ', Country: ' + result['country'] albuminfo = 'Type: ' + result['rgtype'] + ', Country: ' + result['country']
# Constructing CAA URL for fallback, will be used as data-src for unveiling
caa_group_url = "http://coverartarchive.org/release-group/%s/front-250.jpg" %result['rgid'] caa_group_url = "http://coverartarchive.org/release-group/%s/front-250.jpg" %result['rgid']
# MusicBrainz album art URL (assuming it's a direct link to the image)
# If result['image'] provides a direct image URL, use it. Otherwise, rely on CAA or generic.
# For this modernization, let's assume getAlbumArtURL is available or build a generic one.
# For now, will prioritize getImageLinks.
%> %>
<tr class="grade${grade}"> <tr class="grade${grade}">
%if type == 'album': %if type == 'album':
<td class="column-albumart album-art-cell"> <%-- Changed ID to class --%> <td id="albumart" style=" text-align: center; vertical-align: middle;"><div id="artistImg"><img title="${result['albumid']}" class="albumArt" height="50" width="50" onerror="tryCCA(this, '${caa_group_url}')"></div></td>
<div class="artwork-container">
<%-- Using data-src for lazy loading. onerror will handle the tryCCA fallback. --%>
<img title="${result['albumid']}" class="albumArt search-result-albumart"
data-src="" <%-- Will be populated by getImageLinks --%>
onerror="tryCCA(this, '${caa_group_url}', 'album')"
alt="Cover art for ${result['title']}" loading="lazy">
</div>
</td>
%elif type == 'artist': %elif type == 'artist':
<td class="column-albumart artist-art-cell"> <%-- Changed ID to class --%> <td id="albumart"><div id="artistImg"><img title="${result['id']}" class="albumArt" height="50" width="50"></div></td>
<div class="artwork-container">
<%-- Assuming artists might also have an image, or it will be a generic icon. --%>
<img title="${result['id']}" class="albumArt search-result-artistart"
data-src="" <%-- Will be populated by getImageLinks --%>
onerror="this.onerror=null;this.src='interfaces/default/images/no-cover-art.png';"
alt="Image for ${result['uniquename']}" loading="lazy">
</div>
</td>
%else: %else:
<td class="column-albumart series-art-cell"></td> <%-- No artwork for series --%> <td id="albumart"></td>
%endif %endif
%if type == 'album': %if type == 'album':
<td class="column-albumname"><a href="addReleaseById?rid=${result['albumid']}&rgid=${result['rgid']}" title="${albuminfo}">${result['title']}</a></td> <td id="albumname"><a href="addReleaseById?rid=${result['albumid']}&rgid=${result['rgid']}" title="${albuminfo}">${result['title']}</a></td>
<td class="column-artistname-small"><a href="addArtist?artistid=${result['id']}" title="${result['uniquename']}">${result['uniquename']}</a></td> <td id="artistnamesmall"><a href="addArtist?artistid=${result['id']}" title="${result['uniquename']}">${result['uniquename']}</a></td>
<td class="column-format">${result['formats']}</td> <td id="format">${result['formats']}</td>
<td class="column-tracks">${result['tracks']}</td> <td id="tracks">${result['tracks']}</td>
<td class="column-reldate">${result['date']}</td> <td id="reldate">${result['date']}</td>
<td class="column-score"><a href="${result['albumurl']}" title="View on MusicBrainz"><div class="bar"><div class="score" style="width: ${result['score']}px">${result['score']}</div></div></a></td> <td id="score"><a href="${result['albumurl']} "title="View on MusicBrainz"><div class="bar"><div class="score" style="width: ${result['score']}px">${result['score']}</div></div></a></td>
<td class="column-musicbrainz musicbrainz-link-cell"><a href="${result['albumurl']}" target="_blank" rel="noopener noreferrer"><img src="interfaces/default/images/MusicBrainz_Album_Icon.png" title="View on MusicBrainz" height="20" width="20" alt="MusicBrainz Link"></a></td> <td id="musicbrainz" style=" text-align: center; line-height: 0; vertical-align: middle;"><a href="${result['albumurl']}"><img src="interfaces/default/images/MusicBrainz_Album_Icon.png" title="View on MusicBrainz" height="20" width="20"></a></td>
<td class="column-mbrelid" style="display:none;">${result['albumid']}</td> <%-- Move display:none to CSS if always hidden --%> <td id="mbrelid" style="display:none;">${result['albumid']}</td>
%elif type == 'artist': %elif type == 'artist':
<td class="column-artistname"><a href="addArtist?artistid=${result['id']}" title="${result['uniquename']}">${result['uniquename']}</a></td> <td id="artistname"><a href="addArtist?artistid=${result['id']}" title="${result['uniquename']}">${result['uniquename']}</a></td>
<td class="column-score"><a href="${result['url']}" title="View on MusicBrainz"><div class="bar"><div class="score" style="width: ${result['score']}px">${result['score']}</div></div></a></td> <td id="score"><a href="${result['url']} "title="View on MusicBrainz"><div class="bar"><div class="score" style="width: ${result['score']}px">${result['score']}</div></div></a></td>
<td class="column-musicbrainz musicbrainz-link-cell"><a href="${result['url']}" target="_blank" rel="noopener noreferrer"><img src="interfaces/default/images/MusicBrainz_Artist_Icon.png" title="View on MusicBrainz" height="20" width="20" alt="MusicBrainz Link"></a></td> <td id="musicbrainz" style=" text-align: center; line-height: 0; vertical-align: middle;"><a href="${result['url']}"><img src="interfaces/default/images/MusicBrainz_Artist_Icon.png" title="View on MusicBrainz" height="20" width="20"></a></td>
%else: %else:
<td class="column-seriesname"><a href="addSeries?seriesid=${result['id']}" title="${result['uniquename']}">${result['uniquename']}</a></td> <td id="seriesname"><a href="addSeries?seriesid=${result['id']}" title="${result['uniquename']}">${result['uniquename']}</a></td>
<td class="column-type">${result['type']}</td> <td id="type">${result['type']}</td>
<td class="column-score"><a href="${result['url']}" title="View on MusicBrainz"><div class="bar"><div class="score" style="width: ${result['score']}px">${result['score']}</div></div></a></td> <td id="score"><a href="${result['url']} "title="View on MusicBrainz"><div class="bar"><div class="score" style="width: ${result['score']}px">${result['score']}</div></div></a></td>
<td class="column-musicbrainz musicbrainz-link-cell"><a href="${result['url']}" target="_blank" rel="noopener noreferrer"><img src="interfaces/default/images/MusicBrainz_Artist_Icon.png" title="View on MusicBrainz" height="20" width="20" alt="MusicBrainz Link"></a></td> <td id="musicbrainz" style=" text-align: center; line-height: 0; vertical-align: middle;"><a href="${result['url']}"><img src="interfaces/default/images/MusicBrainz_Artist_Icon.png" title="View on MusicBrainz" height="20" width="20"></a></td>
%endif %endif
</tr> </tr>
%endfor %endfor
@@ -98,77 +77,37 @@
</%def> </%def>
<%def name="headIncludes()"> <%def name="headIncludes()">
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
<link rel="stylesheet" href="interfaces/default/css/data_table.css"> <link rel="stylesheet" href="interfaces/default/css/data_table.css">
<style>
/* Basic CSS for album art thumbnail, consistent with other pages */
.search-result-albumart, .search-result-artistart {
height: 50px;
width: 50px;
object-fit: cover; /* Ensures image covers the area without distortion */
vertical-align: middle;
}
/* MusicBrainz icon styling */
.musicbrainz-link-cell img {
height: 20px;
width: 20px;
vertical-align: middle;
}
/* Score bar styling (if not already in data_table.css) */
.bar {
width: 100px; /* Or a fixed width */
background-color: #eee;
border: 1px solid #ccc;
height: 18px; /* Adjust as needed */
line-height: 18px;
text-align: right;
border-radius: 3px;
overflow: hidden; /* Hide overflow if score > 100px */
}
.score {
height: 100%;
background-color: #5cb85c; /* Green for score */
color: #fff;
padding-right: 5px;
font-size: 0.8em;
box-sizing: border-box; /* Include padding in width calculation */
}
</style>
</%def> </%def>
<%def name="javascriptIncludes()"> <%def name="javascriptIncludes()">
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
<script src="js/libs/jquery.unveil.min.js"></script> <script src="js/libs/jquery.unveil.min.js"></script>
<script src="js/libs/jquery.dataTables.min.js"></script> <script src="js/libs/jquery.dataTables.min.js"></script>
<script type="text/javascript"> <script type="text/javascript">
// Global function to try Cover Art Archive or generic fallback function tryCCA(element, url) {
function tryCCA(element, url, type) {
element.onerror = function() { element.onerror = function() {
element.onerror = null; // Prevent infinite loops element.onerror = null;
element.src = "interfaces/default/images/no-cover-art.png"; // Fallback generic image element.src = "interfaces/default/images/no-cover-art.png";
if (type === 'artist') {
// Specific fallback for artists if different
// element.src = "interfaces/default/images/no-artist-art.png";
}
}; };
if (url) { element.src = url;
element.src = url; // Try the provided URL (e.g., CAA)
} else {
element.src = "interfaces/default/images/no-cover-art.png"; // Fallback if no specific URL provided
}
} }
function getArt() {
$("table#searchresults_table tr td#albumart img").each(function(){
var id = $(this).attr('title');
var image = $(this);
// Encapsulate page-specific logic if (!image.hasClass('done')) {
var SearchResultsPage = SearchResultsPage || {}; image.addClass('done');
getImageLinks(image, id, "${type}", true);
SearchResultsPage.initDataTable = function() { }
});
}
function initThisPage() {
$('#searchresults_table').dataTable({ $('#searchresults_table').dataTable({
"bDestroy": true, "bDestroy": true,
"aoColumnDefs": [ "aoColumnDefs": [
{ 'bSortable': false, 'aTargets': [ 0, 7 ] } // Disable sorting for albumart (0) and MusicBrainz icon (7) columns { 'bSortable': false, 'aTargets': [ 0 ] }
], ],
"oLanguage": { "oLanguage": {
"sLengthMenu":"Show _MENU_ results per page", "sLengthMenu":"Show _MENU_ results per page",
@@ -179,62 +118,36 @@
"sSearch" : ""}, "sSearch" : ""},
"iDisplayLength": 25, "iDisplayLength": 25,
"sPaginationType": "full_numbers", "sPaginationType": "full_numbers",
"aaSorting": [], // No initial sorting specified, DataTables default "aaSorting": [],
"fnDrawCallback": function (o) { "fnDrawCallback": function (o) {
// Jump to top of page // Jump to top of page
$('html,body').scrollTop(0); $('html,body').scrollTop(0);
// Re-unveil images after each draw for lazy loading
$("img.search-result-albumart, img.search-result-artistart").unveil();
// Re-populate art for new rows
SearchResultsPage.getArt();
} }
}); });
}; $('#searchresults_table').on("draw.dt", function () {
getArt();
// Custom function to get image links and apply them to data-src $("img").unveil();
SearchResultsPage.getArt = function() {
// Target all relevant image elements that are not yet "done"
$("img.search-result-albumart:not(.done), img.search-result-artistart:not(.done)").each(function(){
var $image = $(this);
var id = $image.attr('title'); // Use title as ID for getImageLinks
// Add 'done' class immediately to prevent re-processing
$image.addClass('done');
// Assuming getImageLinks is a global function that fetches the actual image URL
// and sets it to the data-src attribute.
// If getImageLinks directly sets the src, unveil() will still work, but
// this setup aims for data-src first.
if (typeof getImageLinks === 'function') {
// getImageLinks should ideally return a promise or accept a callback
// and then set $image.attr('data-src', actualImageUrl);
// For now, assuming it handles it internally or synchronously.
getImageLinks($image, id, "${type}", true); // Assuming this sets data-src or src
} else {
console.warn("getImageLinks function is not defined. Image loading might be impacted.");
// Fallback to a generic image if getImageLinks is not available and no data-src is set.
// This might be handled by onerror, but adding here for explicit safety.
if (!$image.attr('data-src') && !$image.attr('src')) {
$image.attr('src', "interfaces/default/images/no-cover-art.png");
}
}
}); });
}; getArt();
resetFilters("album");
}
$(document).ready(function(){ $(document).ready(function(){
initFancybox(); // Assuming initFancybox is a global function initFancybox();
SearchResultsPage.initDataTable(); initThisPage();
SearchResultsPage.getArt(); // Initial call to load images on page load
// This part sets search parameters based on backend data.
// Assuming searchbar and its inputs exist globally.
$("#searchbar input[name=name]").val(${name | json.dumps});
$("#searchbar select[name=type]").val(${type | json.dumps});
// Assuming resetFilters is a global function from common.js
if (typeof resetFilters === 'function') {
resetFilters("album"); // Or adjust based on 'type' if needed
}
}); });
</script> </script>
<script type="text/javascript">
<%!
# Abuse JSON module for escaping JavaScript
import json
%>
$(document).ready(function() {
// Search parameter
$("#searchbar input[name=name]").val(${name | json.dumps});
// Album or artist
$("#searchbar select[name=type]").val(${type | json.dumps});
});
</script>
</%def> </%def>
+4 -36
View File
@@ -1,45 +1,13 @@
<%inherit file="base.html"/> <%inherit file="base.html"/>
<%def name="headIncludes()"> <%def name="headIncludes()">
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
<meta http-equiv="refresh" content="${timer};url=index"> <meta http-equiv="refresh" content="${timer};url=index">
<style>
/* Basic styling for the shutdown message */
#shutdown-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh; /* Full viewport height */
text-align: center;
background-color: #f8f8f8; /* Light background */
color: #333;
font-family: sans-serif; /* A common, readable font */
}
#shutdown-message h1 {
font-size: 2.5em; /* Larger, more prominent text */
color: #555;
}
#shutdown-message i {
margin-right: 15px; /* Space between icon and text */
color: #4CAF50; /* A green color for success/active states */
}
/* If it's truly shutting down or restarting, maybe a different color */
#shutdown-message.restarting i {
color: #ff9800; /* Orange for restart */
}
#shutdown-message.shutting-down i {
color: #f44336; /* Red for shutdown */
}
</style>
</%def> </%def>
<%def name="body()"> <%def name="body()">
<div class="table_wrapper" id="shutdown-container"> <%-- Added a container for centering --%> <div class="table_wrapper">
<div id="shutdown-message" class="${'restarting' if message == 'restarting' else 'shutting-down'}"> <%-- Added class for conditional styling --%> <div id="shutdown">
<h1><i class="fa fa-refresh fa-spin"></i> Headphones is ${message}</h1> <h1><i class="fa fa-refresh fa-spin"></i> Headphones is ${message}</h1>
</div> </div>
</div> </div>
</%def> </%def>
+38 -177
View File
@@ -3,10 +3,7 @@
<%def name="headerIncludes()"> <%def name="headerIncludes()">
<div id="subhead_container"> <div id="subhead_container">
<div id="subhead_menu"> <div id="subhead_menu">
<%-- Changed inline onclick to a class and data attributes for JS handling --%> <a href="javascript:void(0)" id="menu_link_scan" onclick="doAjaxCall('forceSearch',$(this))" data-success="Checking for wanted albums successful" data-error="Error checking wanted albums"><i class="fa fa-search"></i> Force Check</a>
<a href="#" id="menu_link_force_check" class="ajax-link" data-action="forceSearch" data-success="Checking for wanted albums successful" data-error="Error checking wanted albums">
<i class="fa fa-search"></i> Force Check
</a>
</div> </div>
</div> </div>
</%def> </%def>
@@ -16,44 +13,38 @@
<div id="paddingheader"> <div id="paddingheader">
<h1 class="clearfix"><i class="fa fa-heart"></i> Wanted Albums</h1> <h1 class="clearfix"><i class="fa fa-heart"></i> Wanted Albums</h1>
</div> </div>
<form action="markAlbums" method="get" id="markAlbumsForm"> <%-- Added ID to the form --%> <form action="markAlbums" method="get" id="markAlbums">
<div id="mark_albums_controls"> <%-- Unique and descriptive ID, moved inline style to CSS --%> <div id="markalbum" style="top:0;">
Mark selected albums as Mark selected albums as
<select name="action" id="markAlbumActionSelect"> <%-- Added ID --%> <select name="action" onChange="doAjaxCall('markAlbums',$(this),'table',true);" data-error="You didn't select any albums">
<option disabled="disabled" selected="selected">Choose...</option> <option disabled="disabled" selected="selected">Choose...</option>
<option value="Skipped">Skipped</option> <option value="Skipped">Skipped</option>
<option value="Ignored">Ignored</option> <option value="Ignored">Ignored</option>
<option value="Downloaded">Downloaded</option> <option value="Downloaded">Downloaded</option>
</select> </select>
<%-- Replaced hidden input with a visible button if desired, or handle submit via JS --%> <input type="hidden" value="Go">
<%-- For now, keep the JS-driven approach as close as possible to original logic --%>
</div> </div>
<div class="table_wrapper" id="wanted_table_wrapper" > <div class="table_wrapper" id="wanted_table_wrapper" >
<table class="display" id="wanted_table"> <table class="display" id="wanted_table">
<thead> <thead>
<tr> <tr>
<th class="select-all-checkbox"><input type="checkbox" id="toggleAllWanted" /></th> <%-- Added ID for easier targeting --%> <th id="select"><input type="checkbox" onClick="toggle(this)" /></th>
<th class="column-albumart"></th> <th id="albumart"></th>
<th class="column-artistname">Artist</th> <th id="artistname">Artist</th>
<th class="column-albumname">Album Name</th> <th id="albumname">Album Name</th>
<th class="column-reldate">Release Date</th> <th id="reldate">Release Date</th>
<th class="column-type">Type</th> <th id="type">Type</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
%for album in wanted: %for album in wanted:
<tr class="gradeZ"> <tr class="gradeZ">
<td class="select-checkbox"><input type="checkbox" name="album_ids" value="${album['AlbumID']}" class="wanted-album-checkbox" /></td> <%-- Changed name to album_ids, added class --%> <td id="select"><input type="checkbox" name="${album['AlbumID']}" class="checkbox" /></th>
<td class="column-albumart"> <td id="albumart"><img title="${album['AlbumID']}" height="64" width="64" src="interfaces/default/images/no-cover-art.png" data-src="artwork/thumbs/album/${album['AlbumID']}"></td>
<img title="${album['AlbumID']}" height="64" width="64" <td id="artistname"><a href="artistPage?ArtistID=${album['ArtistID']}">${album['ArtistName']}</a></td>
data-src="artwork/thumbs/album/${album['AlbumID']}" <td id="albumname"><a href="albumPage?AlbumID=${album['AlbumID']}">${album['AlbumTitle']}</a></td>
src="interfaces/default/images/no-cover-art.png" <%-- Fallback src --%> <td id="reldate">${album['ReleaseDate']}</td>
alt="Cover art for ${album['AlbumTitle']}" loading="lazy"> <td id="type">${album['Type']}</td>
</td>
<td class="column-artistname"><a href="artistPage?ArtistID=${album['ArtistID']}">${album['ArtistName']}</a></td>
<td class="column-albumname"><a href="albumPage?AlbumID=${album['AlbumID']}">${album['AlbumTitle']}</a></td>
<td class="column-reldate">${album['ReleaseDate']}</td>
<td class="column-type">${album['Type']}</td>
</tr> </tr>
%endfor %endfor
</tbody> </tbody>
@@ -65,31 +56,26 @@
<h1 class="clearfix"><i class="fa fa-calendar"></i> Upcoming Albums</h1> <h1 class="clearfix"><i class="fa fa-calendar"></i> Upcoming Albums</h1>
</div> </div>
<div class="table_wrapper"> <div class="table_wrapper">
<table class="display_no_select" id="upcoming_table"> <%-- No changes here, but ensuring it gets DataTables init --%> <table class="display_no_select" id="upcoming_table">
<thead> <thead>
<tr> <tr>
<th class="column-albumart"></th> <th id="albumart"></th>
<th class="column-artistname">Artist</th> <th id="artistname">Artist</th>
<th class="column-albumname">Album Name</th> <th id="albumname">Album Name</th>
<th class="column-reldate">Release Date</th> <th id="reldate">Release Date</th>
<th class="column-type">Type</th> <th id="type">Type</th>
<th class="column-status">Status</th> <th id="status">Status</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
%for album in upcoming: %for album in upcoming:
<tr class="gradeZ"> <tr class="gradeZ">
<td class="column-albumart"> <td id="albumart"><img title="${album['AlbumID']}" height="64" width="64" data-src="artwork/thumbs/album/${album['AlbumID']}"></td>
<img title="${album['AlbumID']}" height="64" width="64" <td id="artistname"><a href="artistPage?ArtistID=${album['ArtistID']}">${album['ArtistName']}</a></td>
data-src="artwork/thumbs/album/${album['AlbumID']}" <td id="albumname"><a href="albumPage?AlbumID=${album['AlbumID']}">${album['AlbumTitle']}</a></td>
src="interfaces/default/images/no-cover-art.png" <%-- Fallback src --%> <td id="reldate">${album['ReleaseDate']}</td>
alt="Cover art for ${album['AlbumTitle']}" loading="lazy"> <td id="type">${album['Type']}</td>
</td> <td id="status">${album['Status']}</td>
<td class="column-artistname"><a href="artistPage?ArtistID=${album['ArtistID']}">${album['ArtistName']}</a></td>
<td class="column-albumname"><a href="albumPage?AlbumID=${album['AlbumID']}">${album['AlbumTitle']}</a></td>
<td class="column-reldate">${album['ReleaseDate']}</td>
<td class="column-type">${album['Type']}</td>
<td class="column-status">${album['Status']}</td>
</tr> </tr>
%endfor %endfor
</tbody> </tbody>
@@ -98,154 +84,29 @@
</%def> </%def>
<%def name="headIncludes()"> <%def name="headIncludes()">
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
<link rel="stylesheet" href="interfaces/default/css/data_table.css"> <link rel="stylesheet" href="interfaces/default/css/data_table.css">
<style>
#mark_albums_controls {
position: relative; /* Adjust as per original 'top:0;' intent */
margin-bottom: 15px; /* Add some space below controls */
}
/* Style for album art thumbnails */
.column-albumart img {
width: 64px;
height: 64px;
object-fit: cover; /* Ensures image fills the space without distortion */
vertical-align: middle;
}
</style>
</%def> </%def>
<%def name="javascriptIncludes()"> <%def name="javascriptIncludes()">
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
<script src="js/libs/jquery.unveil.min.js"></script> <script src="js/libs/jquery.unveil.min.js"></script>
<script src="js/libs/jquery.dataTables.min.js"></script> <script src="js/libs/jquery.dataTables.min.js"></script>
<script> <script>
// Encapsulate page-specific logic function initThisPage() {
var UpcomingPage = UpcomingPage || {}; $("img").unveil();
UpcomingPage.initDataTables = function() {
// Initialize Wanted Albums table
$('#wanted_table').dataTable({ $('#wanted_table').dataTable({
"oLanguage": { "oLanguage": {
"sEmptyTable": "No wanted albums found" // More descriptive empty table message "sEmptyTable": " "
}, },
"bDestroy": true, "bDestroy": true,
"bFilter": false, "bFilter": false,
"bInfo": false, "bInfo": false,
"bPaginate": false, "bPaginate": false
"aoColumnDefs": [
{ 'bSortable': false, 'aTargets': [ 0, 1 ] } // Disable sorting for checkbox and album art columns
],
"fnDrawCallback": function (o) {
// Re-unveil images after each draw for lazy loading
$("img[data-src]").unveil();
}
}); });
resetFilters("artists");
// Initialize Upcoming Albums table initActions();
$('#upcoming_table').dataTable({ }
"oLanguage": {
"sEmptyTable": "No upcoming albums found" // More descriptive empty table message
},
"bDestroy": true,
"bFilter": false,
"bInfo": false,
"bPaginate": false,
"aoColumnDefs": [
{ 'bSortable': false, 'aTargets': [ 0 ] } // Disable sorting for album art column
],
"aaSorting": [[3, 'asc']], // Sort by Release Date ascending by default
"fnDrawCallback": function (o) {
// Re-unveil images after each draw for lazy loading
$("img[data-src]").unveil();
}
});
};
UpcomingPage.initActions = function() {
// Event listener for "Force Check" link
$('#menu_link_force_check').on('click', function(e) {
e.preventDefault();
var $this = $(this);
var actionUrl = $this.data('action'); // 'forceSearch'
var successMsg = $this.data('success');
var errorMsg = $this.data('error');
if (typeof doAjaxCall === 'function') {
doAjaxCall(actionUrl, $this, 'page', successMsg, errorMsg);
} else {
console.error("doAjaxCall function is not defined. Cannot force check.");
}
});
// Event listener for "Mark selected albums as" dropdown
$('#markAlbumActionSelect').on('change', function() {
var $this = $(this);
var action = $this.val();
var selectedAlbumIds = [];
$('.wanted-album-checkbox:checked').each(function() {
selectedAlbumIds.push($(this).val());
});
if (selectedAlbumIds.length === 0) {
if (typeof showMessage === 'function') {
showMessage($this.data('error') || "You didn't select any albums", 'error');
} else {
alert($this.data('error') || "You didn't select any albums");
}
// Reset dropdown to "Choose..."
$this.val('Choose...');
return;
}
var url = $('#markAlbumsForm').attr('action') + '?action=' + encodeURIComponent(action);
$.each(selectedAlbumIds, function(index, id) {
url += '&album_ids=' + encodeURIComponent(id); // Append selected album IDs
});
var successMsg = 'Albums successfully marked as ' + action;
var errorMsg = 'Error marking albums.';
if (typeof doAjaxCall === 'function') {
doAjaxCall(url, $this, 'table', successMsg, errorMsg);
// Reset dropdown after action
$this.val('Choose...');
} else {
console.error("doAjaxCall function is not defined. Cannot mark albums.");
}
});
// Event listener for the "toggle all" checkbox in Wanted Albums table
$('#toggleAllWanted').on('click', function() {
$('.wanted-album-checkbox').prop('checked', this.checked);
});
// Update "toggle all" checkbox based on individual checkboxes
$(document).on('click', '.wanted-album-checkbox', function() {
if (!this.checked) {
$('#toggleAllWanted').prop('checked', false);
} else {
if ($('.wanted-album-checkbox:checked').length === $('.wanted-album-checkbox').length) {
$('#toggleAllWanted').prop('checked', true);
}
}
});
// Assuming resetFilters and initActions are global functions from common.js
if (typeof resetFilters === 'function') {
resetFilters("artists"); // Or adjust based on context
}
if (typeof initActions === 'function') {
initActions();
}
};
$(document).ready(function() { $(document).ready(function() {
UpcomingPage.initDataTables(); initThisPage();
UpcomingPage.initActions();
// Initial unveil call for images
$("img[data-src]").unveil();
}); });
</script> </script>
</%def> </%def>
+6 -31
View File
@@ -28,34 +28,11 @@ def getAlbumArt(albumid):
# CAA # CAA
logger.info("Searching for artwork at CAA") logger.info("Searching for artwork at CAA")
#artwork_path = 'https://coverartarchive.org/release-group/%s/front' % albumid artwork_path = 'https://coverartarchive.org/release-group/%s/front' % albumid
artwork_path = 'https://coverartarchive.org/release-group/%s' % albumid artwork = getartwork(artwork_path)
if artwork:
data = request.request_json(artwork_path, timeout=20, whitelist_status_code=404) logger.info("Artwork found at CAA")
return artwork_path, artwork
image_url = None
if data:
for item in data.get("images", []):
try:
if "Front" not in item["types"]:
continue
# Use desired size
image_url = item["image"]
if headphones.CONFIG.ALBUM_ART_MAX_WIDTH:
if isinstance(item.get("thumbnails"), dict):
image_url = item["thumbnails"].get(
headphones.CONFIG.ALBUM_ART_MAX_WIDTH, image_url
)
break
except KeyError:
pass
if image_url:
artwork = getartwork(image_url)
if artwork:
logger.info("Artwork found at CAA")
return artwork_path, artwork
# Amazon # Amazon
logger.info("Searching for artwork at Amazon") logger.info("Searching for artwork at Amazon")
@@ -185,14 +162,12 @@ def getartwork(artwork_path):
"url": artwork_path, "url": artwork_path,
"w": maxwidth "w": maxwidth
} }
headers = {"User-Agent": "Headphones"}
r = request.request_response( r = request.request_response(
url, url,
params=params, params=params,
timeout=20, timeout=20,
stream=True, stream=True,
whitelist_status_code=404, whitelist_status_code=404
headers=headers
) )
if r: if r:
for chunk in r.iter_content(chunk_size=1024): for chunk in r.iter_content(chunk_size=1024):
+6 -2
View File
@@ -474,8 +474,12 @@ class Api(object):
# Handle situations where the torrent url contains arguments that are # Handle situations where the torrent url contains arguments that are
# parsed # parsed
if kwargs: if kwargs:
import urllib.request, urllib.parse, urllib.error import urllib.request
import urllib.request, urllib.error, urllib.parse import urllib.parse
import urllib.error
import urllib.request
import urllib.error
import urllib.parse
url = urllib.parse.quote( url = urllib.parse.quote(
url, safe=":?/=&") + '&' + urllib.parse.urlencode(kwargs) url, safe=":?/=&") + '&' + urllib.parse.urlencode(kwargs)
-166
View File
@@ -1,166 +0,0 @@
# 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 headphones
import json
import os
import re
from headphones import logger, helpers, metadata, request
from headphones.common import USER_AGENT
from headphones.types import Result
from mediafile import MediaFile, UnreadableFileError
from bs4 import BeautifulSoup
from bs4 import FeatureNotFound
def search(album, albumlength=None, page=1, resultlist=None):
dic = {'...': '', ' & ': ' ', ' = ': ' ', '?': '', '$': 's', ' + ': ' ',
'"': '', ',': '', '*': '', '.': '', ':': ''}
if resultlist is None:
resultlist = []
cleanalbum = helpers.latinToAscii(
helpers.replace_all(album['AlbumTitle'], dic)
).strip()
cleanartist = helpers.latinToAscii(
helpers.replace_all(album['ArtistName'], dic)
).strip()
headers = {'User-Agent': USER_AGENT}
params = {
"page": page,
"q": cleanalbum,
}
logger.info("Looking up https://bandcamp.com/search with {}".format(
params))
content = request.request_content(
url='https://bandcamp.com/search',
params=params,
headers=headers
).decode('utf8')
try:
soup = BeautifulSoup(content, "html5lib")
except FeatureNotFound:
soup = BeautifulSoup(content, "html.parser")
for item in soup.find_all("li", class_="searchresult"):
type = item.find('div', class_='itemtype').text.strip().lower()
if type == "album":
data = parse_album(item)
cleanartist_found = helpers.latinToAscii(data['artist'])
cleanalbum_found = helpers.latinToAscii(data['album'])
logger.debug(u"{} - {}".format(data['album'], cleanalbum_found))
logger.debug("Comparing {} to {}".format(
cleanalbum, cleanalbum_found))
if (cleanartist.lower() == cleanartist_found.lower() and
cleanalbum.lower() == cleanalbum_found.lower()):
resultlist.append(Result(
data['title'], data['size'], data['url'],
'bandcamp', 'bandcamp', True))
else:
continue
if(soup.find('a', class_='next')):
page += 1
logger.debug("Calling next page ({})".format(page))
search(album, albumlength=albumlength,
page=page, resultlist=resultlist)
return resultlist
def download(album, bestqual):
html = request.request_content(url=bestqual.url).decode('utf-8')
trackinfo = []
try:
trackinfo = json.loads(
re.search(r"trackinfo&quot;:(\[.*?\]),", html)
.group(1)
.replace('&quot;', '"'))
except ValueError as e:
logger.warn("Couldn't load json: {}".format(e))
directory = os.path.join(
headphones.CONFIG.BANDCAMP_DIR,
u'{} - {}'.format(
album['ArtistName'].replace('/', '_'),
album['AlbumTitle'].replace('/', '_')))
directory = helpers.latinToAscii(directory)
if not os.path.exists(directory):
try:
os.makedirs(directory)
except Exception as e:
logger.warn("Could not create directory ({})".format(e))
index = 1
for track in trackinfo:
filename = helpers.replace_illegal_chars(
u'{:02d} - {}.mp3'.format(index, track['title']))
fullname = os.path.join(directory.encode('utf-8'),
filename.encode('utf-8'))
logger.debug("Downloading to {}".format(fullname))
if 'file' in track and track['file'] != None and 'mp3-128' in track['file']:
content = request.request_content(track['file']['mp3-128'])
open(fullname, 'wb').write(content)
try:
f = MediaFile(fullname)
date, year = metadata._date_year(album)
f.update({
'artist': album['ArtistName'].encode('utf-8'),
'album': album['AlbumTitle'].encode('utf-8'),
'title': track['title'].encode('utf-8'),
'track': track['track_num'],
'tracktotal': len(trackinfo),
'year': year,
})
f.save()
except UnreadableFileError as ex:
logger.warn("MediaFile couldn't parse: %s (%s)",
fullname,
str(ex))
index += 1
return directory
def parse_album(item):
album = item.find('div', class_='heading').text.strip()
artist = item.find('div', class_='subhead').text.strip().replace("by ", "")
released = item.find('div', class_='released').text.strip().replace(
"released ", "")
year = re.search(r"(\d{4})", released).group(1)
url = item.find('div', class_='heading').find('a')['href'].split("?")[0]
length = item.find('div', class_='length').text.strip()
tracks, minutes = length.split(",")
tracks = tracks.replace(" tracks", "").replace(" track", "").strip()
minutes = minutes.replace(" minutes", "").strip()
# bandcamp offers mp3 128b with should be 960KB/minute
size = int(minutes) * 983040
data = {"title": u'{} - {} [{}]'.format(artist, album, year),
"artist": artist, "album": album,
"url": url, "size": size}
return data
+5 -7
View File
@@ -388,9 +388,9 @@ class Cache(object):
else: else:
if dbalbum['Type'] != "part of": if dbalbum['Type'] != "part of":
data = lastfm.request_lastfm("album.getinfo", data = lastfm.request_lastfm("album.getinfo",
artist=helpers.clean_musicbrainz_name(dbalbum['ArtistName']), artist=helpers.clean_musicbrainz_name(dbalbum['ArtistName']),
album=helpers.clean_musicbrainz_name(dbalbum['AlbumTitle']), album=helpers.clean_musicbrainz_name(dbalbum['AlbumTitle']),
api_key=LASTFM_API_KEY) api_key=LASTFM_API_KEY)
else: else:
# Series, use actual artist for the release-group # Series, use actual artist for the release-group
@@ -484,7 +484,7 @@ class Cache(object):
self.id + '_fanart_' + '.' + helpers.today() + ext) self.id + '_fanart_' + '.' + helpers.today() + ext)
else: else:
artwork_path = os.path.join(self.path_to_art_cache, artwork_path = os.path.join(self.path_to_art_cache,
self.id + '.' + helpers.today() + ext) self.id + '.' + helpers.today() + ext)
try: try:
with open(artwork_path, 'wb') as f: with open(artwork_path, 'wb') as f:
f.write(artwork) f.write(artwork)
@@ -545,13 +545,11 @@ class Cache(object):
"url": thumb_url, "url": thumb_url,
"w": 300 "w": 300
} }
headers = {"User-Agent": "Headphones"}
artwork_thumb = request.request_content( artwork_thumb = request.request_content(
url, url,
params=params, params=params,
timeout=20, timeout=20,
whitelist_status_code=404, whitelist_status_code=404
headers=headers
) )
if artwork_thumb: if artwork_thumb:
with open(thumb_path, 'wb') as f: with open(thumb_path, 'wb') as f:
+3 -1
View File
@@ -18,7 +18,9 @@
####################################### #######################################
import urllib.request, urllib.parse, urllib.error import urllib.request
import urllib.parse
import urllib.error
from .common import USER_AGENT from .common import USER_AGENT
+38 -1
View File
@@ -77,7 +77,7 @@ class Quality:
toReturn = {} toReturn = {}
for x in list(Quality.qualityStrings.keys()): for x in list(Quality.qualityStrings.keys()):
toReturn[Quality.compositeStatus(status, x)] = Quality.statusPrefixes[status] + " (" + \ toReturn[Quality.compositeStatus(status, x)] = Quality.statusPrefixes[status] + " (" + \
Quality.qualityStrings[x] + ")" Quality.qualityStrings[x] + ")"
return toReturn return toReturn
@staticmethod @staticmethod
@@ -102,6 +102,36 @@ class Quality:
return (anyQualities, bestQualities) return (anyQualities, bestQualities)
@staticmethod
def nameQuality(name):
def checkName(list, func):
return func([re.search(x, name, re.I) for x in list])
name = os.path.basename(name)
# if we have our exact text then assume we put it there
for x in Quality.qualityStrings:
if x == Quality.UNKNOWN:
continue
regex = '\W' + Quality.qualityStrings[x].replace(' ', '\W') + '\W'
regex_match = re.search(regex, name, re.I)
if regex_match:
return x
# TODO: fix quality checking here
if checkName(["mp3", "192"], any) and not checkName(["flac"], all):
return Quality.B192
elif checkName(["mp3", "256"], any) and not checkName(["flac"], all):
return Quality.B256
elif checkName(["mp3", "vbr"], any) and not checkName(["flac"], all):
return Quality.VBR
elif checkName(["mp3", "320"], any) and not checkName(["flac"], all):
return Quality.B320
else:
return Quality.UNKNOWN
@staticmethod @staticmethod
def assumeQuality(name): def assumeQuality(name):
if name.lower().endswith(".mp3"): if name.lower().endswith(".mp3"):
@@ -128,6 +158,13 @@ class Quality:
return (Quality.NONE, status) return (Quality.NONE, status)
@staticmethod
def statusFromName(name, assume=True):
quality = Quality.nameQuality(name)
if assume and quality == Quality.UNKNOWN:
quality = Quality.assumeQuality(name)
return Quality.compositeStatus(DOWNLOADED, quality)
DOWNLOADED = None DOWNLOADED = None
SNATCHED = None SNATCHED = None
SNATCHED_PROPER = None SNATCHED_PROPER = None
+20 -23
View File
@@ -31,6 +31,7 @@ class path(str):
def __repr__(self): def __repr__(self):
return 'headphones.config.path(%s)' % self return 'headphones.config.path(%s)' % self
_CONFIG_DEFINITIONS = { _CONFIG_DEFINITIONS = {
'ADD_ALBUM_ART': (int, 'General', 0), 'ADD_ALBUM_ART': (int, 'General', 0),
'ADVANCEDENCODER': (str, 'General', ''), 'ADVANCEDENCODER': (str, 'General', ''),
@@ -80,7 +81,6 @@ _CONFIG_DEFINITIONS = {
'DELUGE_PASSWORD': (str, 'Deluge', ''), 'DELUGE_PASSWORD': (str, 'Deluge', ''),
'DELUGE_LABEL': (str, 'Deluge', ''), 'DELUGE_LABEL': (str, 'Deluge', ''),
'DELUGE_DONE_DIRECTORY': (str, 'Deluge', ''), 'DELUGE_DONE_DIRECTORY': (str, 'Deluge', ''),
'DELUGE_DOWNLOAD_DIRECTORY': (str, 'Deluge', ''),
'DELUGE_PAUSED': (int, 'Deluge', 0), 'DELUGE_PAUSED': (int, 'Deluge', 0),
'DESTINATION_DIR': (str, 'General', ''), 'DESTINATION_DIR': (str, 'General', ''),
'DETECT_BITRATE': (int, 'General', 0), 'DETECT_BITRATE': (int, 'General', 0),
@@ -156,10 +156,9 @@ _CONFIG_DEFINITIONS = {
'KEEP_TORRENT_FILES': (int, 'General', 0), 'KEEP_TORRENT_FILES': (int, 'General', 0),
'KEEP_TORRENT_FILES_DIR': (path, 'General', ''), 'KEEP_TORRENT_FILES_DIR': (path, 'General', ''),
'LASTFM_USERNAME': (str, 'General', ''), 'LASTFM_USERNAME': (str, 'General', ''),
'LASTFM_APIKEY': (str, 'General', ''),
'LAUNCH_BROWSER': (int, 'General', 1), 'LAUNCH_BROWSER': (int, 'General', 1),
'LIBRARYSCAN': (int, 'General', 1), 'LIBRARYSCAN': (int, 'General', 1),
'LIBRARYSCAN_INTERVAL': (int, 'General', 24), 'LIBRARYSCAN_INTERVAL': (int, 'General', 300),
'LMS_ENABLED': (int, 'LMS', 0), 'LMS_ENABLED': (int, 'LMS', 0),
'LMS_HOST': (str, 'LMS', ''), 'LMS_HOST': (str, 'LMS', ''),
'LOG_DIR': (path, 'General', ''), 'LOG_DIR': (path, 'General', ''),
@@ -203,6 +202,9 @@ _CONFIG_DEFINITIONS = {
'PIRATEBAY': (int, 'Piratebay', 0), 'PIRATEBAY': (int, 'Piratebay', 0),
'PIRATEBAY_PROXY_URL': (str, 'Piratebay', ''), 'PIRATEBAY_PROXY_URL': (str, 'Piratebay', ''),
'PIRATEBAY_RATIO': (str, 'Piratebay', ''), 'PIRATEBAY_RATIO': (str, 'Piratebay', ''),
'OLDPIRATEBAY': (int, 'Old Piratebay', 0),
'OLDPIRATEBAY_URL': (str, 'Old Piratebay', ''),
'OLDPIRATEBAY_RATIO': (str, 'Old Piratebay', ''),
'PLEX_CLIENT_HOST': (str, 'Plex', ''), 'PLEX_CLIENT_HOST': (str, 'Plex', ''),
'PLEX_ENABLED': (int, 'Plex', 0), 'PLEX_ENABLED': (int, 'Plex', 0),
'PLEX_NOTIFY': (int, 'Plex', 0), 'PLEX_NOTIFY': (int, 'Plex', 0),
@@ -239,7 +241,6 @@ _CONFIG_DEFINITIONS = {
'QBITTORRENT_PASSWORD': (str, 'QBitTorrent', ''), 'QBITTORRENT_PASSWORD': (str, 'QBitTorrent', ''),
'QBITTORRENT_USERNAME': (str, 'QBitTorrent', ''), 'QBITTORRENT_USERNAME': (str, 'QBitTorrent', ''),
'RENAME_FILES': (int, 'General', 0), 'RENAME_FILES': (int, 'General', 0),
'RENAME_SINGLE_DISC_IGNORE': (int, 'General', 0),
'RENAME_UNPROCESSED': (bool_int, 'General', 1), 'RENAME_UNPROCESSED': (bool_int, 'General', 1),
'RENAME_FROZEN': (bool_int, 'General', 1), 'RENAME_FROZEN': (bool_int, 'General', 1),
'REPLACE_EXISTING_FOLDERS': (int, 'General', 0), 'REPLACE_EXISTING_FOLDERS': (int, 'General', 0),
@@ -267,11 +268,6 @@ _CONFIG_DEFINITIONS = {
'SONGKICK_ENABLED': (int, 'Songkick', 1), 'SONGKICK_ENABLED': (int, 'Songkick', 1),
'SONGKICK_FILTER_ENABLED': (int, 'Songkick', 0), 'SONGKICK_FILTER_ENABLED': (int, 'Songkick', 0),
'SONGKICK_LOCATION': (str, 'Songkick', ''), 'SONGKICK_LOCATION': (str, 'Songkick', ''),
'SOULSEEK_API_URL': (str, 'Soulseek', ''),
'SOULSEEK_API_KEY': (str, 'Soulseek', ''),
'SOULSEEK_DOWNLOAD_DIR': (str, 'Soulseek', ''),
'SOULSEEK_INCOMPLETE_DOWNLOAD_DIR': (str, 'Soulseek', ''),
'SOULSEEK': (int, 'Soulseek', 0),
'SUBSONIC_ENABLED': (int, 'Subsonic', 0), 'SUBSONIC_ENABLED': (int, 'Subsonic', 0),
'SUBSONIC_HOST': (str, 'Subsonic', ''), 'SUBSONIC_HOST': (str, 'Subsonic', ''),
'SUBSONIC_PASSWORD': (str, 'Subsonic', ''), 'SUBSONIC_PASSWORD': (str, 'Subsonic', ''),
@@ -305,8 +301,11 @@ _CONFIG_DEFINITIONS = {
'UTORRENT_USERNAME': (str, 'uTorrent', ''), 'UTORRENT_USERNAME': (str, 'uTorrent', ''),
'VERIFY_SSL_CERT': (bool_int, 'Advanced', 1), 'VERIFY_SSL_CERT': (bool_int, 'Advanced', 1),
'WAIT_UNTIL_RELEASE_DATE': (int, 'General', 0), 'WAIT_UNTIL_RELEASE_DATE': (int, 'General', 0),
'WAFFLES': (int, 'Waffles', 0),
'WAFFLES_PASSKEY': (str, 'Waffles', ''),
'WAFFLES_RATIO': (str, 'Waffles', ''),
'WAFFLES_UID': (str, 'Waffles', ''),
'REDACTED': (int, 'Redacted', 0), 'REDACTED': (int, 'Redacted', 0),
'REDACTED_APIKEY': (str, 'Redacted', ''),
'REDACTED_USERNAME': (str, 'Redacted', ''), 'REDACTED_USERNAME': (str, 'Redacted', ''),
'REDACTED_PASSWORD': (str, 'Redacted', ''), 'REDACTED_PASSWORD': (str, 'Redacted', ''),
'REDACTED_RATIO': (str, 'Redacted', ''), 'REDACTED_RATIO': (str, 'Redacted', ''),
@@ -317,9 +316,7 @@ _CONFIG_DEFINITIONS = {
'XBMC_PASSWORD': (str, 'XBMC', ''), 'XBMC_PASSWORD': (str, 'XBMC', ''),
'XBMC_UPDATE': (int, 'XBMC', 0), 'XBMC_UPDATE': (int, 'XBMC', 0),
'XBMC_USERNAME': (str, 'XBMC', ''), 'XBMC_USERNAME': (str, 'XBMC', ''),
'XLDPROFILE': (str, 'General', ''), 'XLDPROFILE': (str, 'General', '')
'BANDCAMP': (int, 'General', 0),
'BANDCAMP_DIR': (path, 'General', '')
} }
@@ -331,7 +328,7 @@ class Config(object):
def __init__(self, config_file): def __init__(self, config_file):
""" Initialize the config with values from a file """ """ Initialize the config with values from a file """
self._config_file = config_file self._config_file = config_file
self._config = ConfigParser(interpolation=None) self._config = ConfigParser()
self._config.read(self._config_file) self._config.read(self._config_file)
for key in list(_CONFIG_DEFINITIONS.keys()): for key in list(_CONFIG_DEFINITIONS.keys()):
self.check_setting(key) self.check_setting(key)
@@ -367,12 +364,12 @@ class Config(object):
try: try:
my_val = definition_type(self._config[section][ini_key]) my_val = definition_type(self._config[section][ini_key])
# ConfigParser interprets quotes in the config # ConfigParser interprets empty strings in the config
# literally, so we need to sanitize it. It's not really # literally, so we need to sanitize it. It's not really
# a config upgrade, since a user can at any time put # a config upgrade, since a user can at any time put
# some_key = 'some_val' # some_key = ''
if type(my_val) in [str, path]: if my_val == '""' or my_val == "''":
my_val = my_val.strip('"').strip("'") my_val = ''
except Exception: except Exception:
my_val = default my_val = default
self._config[section][ini_key] = str(my_val) self._config[section][ini_key] = str(my_val)
@@ -380,7 +377,7 @@ class Config(object):
def write(self): def write(self):
""" Make a copy of the stored config and write it to the configured file """ """ Make a copy of the stored config and write it to the configured file """
new_config = ConfigParser(interpolation=None) new_config = ConfigParser()
# first copy over everything from the old config, even if it is not # first copy over everything from the old config, even if it is not
# correctly defined to keep from losing data # correctly defined to keep from losing data
@@ -411,7 +408,7 @@ class Config(object):
""" Return the extra newznab tuples """ """ Return the extra newznab tuples """
extra_newznabs = list( extra_newznabs = list(
zip(*[itertools.islice(self.EXTRA_NEWZNABS, i, None, 3) zip(*[itertools.islice(self.EXTRA_NEWZNABS, i, None, 3)
for i in range(3)]) for i in range(3)])
) )
return extra_newznabs return extra_newznabs
@@ -430,7 +427,7 @@ class Config(object):
""" Return the extra torznab tuples """ """ Return the extra torznab tuples """
extra_torznabs = list( extra_torznabs = list(
zip(*[itertools.islice(self.EXTRA_TORZNABS, i, None, 4) zip(*[itertools.islice(self.EXTRA_TORZNABS, i, None, 4)
for i in range(4)]) for i in range(4)])
) )
return extra_torznabs return extra_torznabs
@@ -507,7 +504,7 @@ class Config(object):
if self.EXTRA_TORZNABS: if self.EXTRA_TORZNABS:
extra_torznabs = list( extra_torznabs = list(
zip(*[itertools.islice(self.EXTRA_TORZNABS, i, None, 3) zip(*[itertools.islice(self.EXTRA_TORZNABS, i, None, 3)
for i in range(3)]) for i in range(3)])
) )
new_torznabs = [] new_torznabs = []
for torznab in extra_torznabs: for torznab in extra_torznabs:
+1 -2
View File
@@ -18,7 +18,6 @@
################################### ###################################
import time import time
import sqlite3 import sqlite3
@@ -117,7 +116,7 @@ class DBConnection:
break break
except sqlite3.OperationalError as e: except sqlite3.OperationalError as e:
if "unable to open database file" in str(e) or "database is locked" in str(e): if "unable to open database file" in e.message or "database is locked" in e.message:
dberror = e dberror = e
if args is None: if args is None:
logger.debug('Database error: %s. Query: %s', e, query) logger.debug('Database error: %s. Query: %s', e, query)
+94 -50
View File
@@ -35,7 +35,6 @@
# along with SickRage. If not, see <http://www.gnu.org/licenses/>. # along with SickRage. If not, see <http://www.gnu.org/licenses/>.
from headphones import logger from headphones import logger
import time import time
@@ -58,12 +57,12 @@ def _scrubber(text):
if scrub_logs: if scrub_logs:
try: try:
# URL parameter values # URL parameter values
text = re.sub(r'=[0-9a-zA-Z]*', r'=REMOVED', text) text = re.sub('=[0-9a-zA-Z]*', '=REMOVED', text)
# Local host with port # Local host with port
# text = re.sub('\:\/\/.*\:', '://REMOVED:', text) # just host # text = re.sub('\:\/\/.*\:', '://REMOVED:', text) # just host
text = re.sub(r'\:\/\/.*\:[0-9]*', r'://REMOVED:', text) text = re.sub('\:\/\/.*\:[0-9]*', '://REMOVED:', text)
# Session cookie # Session cookie
text = re.sub(r"_session_id'\: '.*'", r"_session_id': 'REMOVED'", text) text = re.sub("_session_id'\: '.*'", "_session_id': 'REMOVED'", text)
# Local Windows user path # Local Windows user path
if text.lower().startswith('c:\\users\\'): if text.lower().startswith('c:\\users\\'):
k = text.split('\\') k = text.split('\\')
@@ -84,15 +83,19 @@ def addTorrent(link, data=None, name=None):
result = {} result = {}
retid = False retid = False
url_orpheus = ['https://orpheus.network/', 'http://orpheus.network/'] url_orpheus = ['https://orpheus.network/', 'http://orpheus.network/']
url_waffles = ['https://waffles.ch/', 'http://waffles.ch/']
if link.lower().startswith('magnet:'): if link.lower().startswith('magnet:'):
logger.debug('Deluge: Got a magnet link: %s' % _scrubber(link)) logger.debug('Deluge: Got a magnet link: %s' % _scrubber(link))
result = {'type': 'magnet', result = {'type': 'magnet',
'url': link} 'url': link}
retid = _add_torrent_magnet(result) retid = _add_torrent_magnet(result)
elif link.lower().startswith('http://') or link.lower().startswith('https://'): elif link.lower().startswith('http://') or link.lower().startswith('https://'):
logger.debug('Deluge: Got a URL: %s' % _scrubber(link)) logger.debug('Deluge: Got a URL: %s' % _scrubber(link))
if link.lower().startswith(tuple(url_waffles)):
if 'rss=' not in link:
link = link + '&rss=1'
if link.lower().startswith(tuple(url_orpheus)): if link.lower().startswith(tuple(url_orpheus)):
logger.debug('Deluge: Using different User-Agent for this site') logger.debug('Deluge: Using different User-Agent for this site')
user_agent = 'Headphones' user_agent = 'Headphones'
@@ -124,9 +127,9 @@ def addTorrent(link, data=None, name=None):
# Extract torrent name from .torrent # Extract torrent name from .torrent
try: try:
logger.debug('Deluge: Getting torrent name length') logger.debug('Deluge: Getting torrent name length')
name_length = int(re.findall(r'name([0-9]*)\:.*?\:', str(torrentfile))[0]) name_length = int(re.findall('name([0-9]*)\:.*?\:', str(torrentfile))[0])
logger.debug('Deluge: Getting torrent name') logger.debug('Deluge: Getting torrent name')
name = re.findall(r'name[0-9]*\:(.*?)\:', str(torrentfile))[0][:name_length] name = re.findall('name[0-9]*\:(.*?)\:', str(torrentfile))[0][:name_length]
except Exception as e: except Exception as e:
logger.debug('Deluge: Could not get torrent name, getting file name') logger.debug('Deluge: Could not get torrent name, getting file name')
# get last part of link/path (name only) # get last part of link/path (name only)
@@ -139,8 +142,8 @@ def addTorrent(link, data=None, name=None):
except: except:
logger.debug('Deluge: Sending Deluge torrent with problematic name and some content') logger.debug('Deluge: Sending Deluge torrent with problematic name and some content')
result = {'type': 'torrent', result = {'type': 'torrent',
'name': name, 'name': name,
'content': torrentfile} 'content': torrentfile}
retid = _add_torrent_file(result) retid = _add_torrent_file(result)
# elif link.endswith('.torrent') or data: # elif link.endswith('.torrent') or data:
@@ -156,9 +159,9 @@ def addTorrent(link, data=None, name=None):
# Extract torrent name from .torrent # Extract torrent name from .torrent
try: try:
logger.debug('Deluge: Getting torrent name length') logger.debug('Deluge: Getting torrent name length')
name_length = int(re.findall(r'name([0-9]*)\:.*?\:', str(torrentfile))[0]) name_length = int(re.findall('name([0-9]*)\:.*?\:', str(torrentfile))[0])
logger.debug('Deluge: Getting torrent name') logger.debug('Deluge: Getting torrent name')
name = re.findall(r'name[0-9]*\:(.*?)\:', str(torrentfile))[0][:name_length] name = re.findall('name[0-9]*\:(.*?)\:', str(torrentfile))[0][:name_length]
except Exception as e: except Exception as e:
logger.debug('Deluge: Could not get torrent name, getting file name') logger.debug('Deluge: Could not get torrent name, getting file name')
# get last part of link/path (name only) # get last part of link/path (name only)
@@ -171,8 +174,8 @@ def addTorrent(link, data=None, name=None):
except UnicodeDecodeError: except UnicodeDecodeError:
logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name.decode('utf-8'), str(torrentfile)[:40])) logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name.decode('utf-8'), str(torrentfile)[:40]))
result = {'type': 'torrent', result = {'type': 'torrent',
'name': name, 'name': name,
'content': torrentfile} 'content': torrentfile}
retid = _add_torrent_file(result) retid = _add_torrent_file(result)
else: else:
@@ -204,7 +207,7 @@ def getTorrentFolder(result):
], ],
"id": 21}) "id": 21})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers) verify=deluge_verify_cert, headers=headers)
result['total_done'] = json.loads(response.text)['result']['total_done'] result['total_done'] = json.loads(response.text)['result']['total_done']
tries = 0 tries = 0
@@ -212,7 +215,7 @@ def getTorrentFolder(result):
tries += 1 tries += 1
time.sleep(5) time.sleep(5)
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers) verify=deluge_verify_cert, headers=headers)
result['total_done'] = json.loads(response.text)['result']['total_done'] result['total_done'] = json.loads(response.text)['result']['total_done']
post_data = json.dumps({"method": "web.get_torrent_status", post_data = json.dumps({"method": "web.get_torrent_status",
@@ -231,7 +234,7 @@ def getTorrentFolder(result):
"id": 23}) "id": 23})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers) verify=deluge_verify_cert, headers=headers)
result['save_path'] = json.loads(response.text)['result']['save_path'] result['save_path'] = json.loads(response.text)['result']['save_path']
result['name'] = json.loads(response.text)['result']['name'] result['name'] = json.loads(response.text)['result']['name']
@@ -260,7 +263,7 @@ def removeTorrent(torrentid, remove_data=False):
"id": 26}) "id": 26})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers) verify=deluge_verify_cert, headers=headers)
try: try:
state = json.loads(response.text)['result']['state'] state = json.loads(response.text)['result']['state']
@@ -279,10 +282,10 @@ def removeTorrent(torrentid, remove_data=False):
"params": [ "params": [
torrentid, torrentid,
remove_data remove_data
], ],
"id": 25}) "id": 25})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers) verify=deluge_verify_cert, headers=headers)
result = json.loads(response.text)['result'] result = json.loads(response.text)['result']
return result return result
@@ -325,12 +328,12 @@ def _get_auth():
"id": 1}) "id": 1})
try: try:
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers) verify=deluge_verify_cert, headers=headers)
except requests.ConnectionError: except requests.ConnectionError:
try: try:
logger.debug('Deluge: Connection failed, let\'s try HTTPS just in case') logger.debug('Deluge: Connection failed, let\'s try HTTPS just in case')
response = requests.post(delugeweb_url.replace('http:', 'https:'), data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url.replace('http:', 'https:'), data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers) verify=deluge_verify_cert, headers=headers)
# If the previous line didn't fail, change delugeweb_url for the rest of this session # If the previous line didn't fail, change delugeweb_url for the rest of this session
logger.error('Deluge: Switching to HTTPS, but certificate won\'t be verified because NO CERTIFICATE WAS CONFIGURED!') logger.error('Deluge: Switching to HTTPS, but certificate won\'t be verified because NO CERTIFICATE WAS CONFIGURED!')
delugeweb_url = delugeweb_url.replace('http:', 'https:') delugeweb_url = delugeweb_url.replace('http:', 'https:')
@@ -355,7 +358,7 @@ def _get_auth():
"id": 10}) "id": 10})
try: try:
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers) verify=deluge_verify_cert, headers=headers)
except Exception as e: except Exception as e:
logger.error('Deluge: Authentication failed: %s' % str(e)) logger.error('Deluge: Authentication failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines() formatted_lines = traceback.format_exc().splitlines()
@@ -372,7 +375,7 @@ def _get_auth():
"id": 11}) "id": 11})
try: try:
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers) verify=deluge_verify_cert, headers=headers)
except Exception as e: except Exception as e:
logger.error('Deluge: Authentication failed: %s' % str(e)) logger.error('Deluge: Authentication failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines() formatted_lines = traceback.format_exc().splitlines()
@@ -391,7 +394,7 @@ def _get_auth():
try: try:
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers) verify=deluge_verify_cert, headers=headers)
except Exception as e: except Exception as e:
logger.error('Deluge: Authentication failed: %s' % str(e)) logger.error('Deluge: Authentication failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines() formatted_lines = traceback.format_exc().splitlines()
@@ -404,7 +407,7 @@ def _get_auth():
try: try:
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers) verify=deluge_verify_cert, headers=headers)
except Exception as e: except Exception as e:
logger.error('Deluge: Authentication failed: %s' % str(e)) logger.error('Deluge: Authentication failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines() formatted_lines = traceback.format_exc().splitlines()
@@ -429,7 +432,7 @@ def _add_torrent_magnet(result):
"params": [result['url'], {}], "params": [result['url'], {}],
"id": 2}) "id": 2})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers) verify=deluge_verify_cert, headers=headers)
result['hash'] = json.loads(response.text)['result'] result['hash'] = json.loads(response.text)['result']
logger.debug('Deluge: Response was %s' % str(json.loads(response.text))) logger.debug('Deluge: Response was %s' % str(json.loads(response.text)))
return json.loads(response.text)['result'] return json.loads(response.text)['result']
@@ -449,7 +452,7 @@ def _add_torrent_url(result):
"params": [result['url'], {}], "params": [result['url'], {}],
"id": 32}) "id": 32})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers) verify=deluge_verify_cert, headers=headers)
result['location'] = json.loads(response.text)['result'] result['location'] = json.loads(response.text)['result']
logger.debug('Deluge: Response was %s' % str(json.loads(response.text))) logger.debug('Deluge: Response was %s' % str(json.loads(response.text)))
return json.loads(response.text)['result'] return json.loads(response.text)['result']
@@ -462,30 +465,13 @@ def _add_torrent_url(result):
def _add_torrent_file(result): def _add_torrent_file(result):
logger.debug('Deluge: Adding file') logger.debug('Deluge: Adding file')
options = {}
if headphones.CONFIG.DELUGE_DOWNLOAD_DIRECTORY:
options['download_location'] = headphones.CONFIG.DELUGE_DOWNLOAD_DIRECTORY
if headphones.CONFIG.DELUGE_DONE_DIRECTORY or headphones.CONFIG.DOWNLOAD_TORRENT_DIR:
options['move_completed'] = 1
if headphones.CONFIG.DELUGE_DONE_DIRECTORY:
options['move_completed_path'] = headphones.CONFIG.DELUGE_DONE_DIRECTORY
else:
options['move_completed_path'] = headphones.CONFIG.DOWNLOAD_TORRENT_DIR
if headphones.CONFIG.DELUGE_PAUSED:
options['add_paused'] = headphones.CONFIG.DELUGE_PAUSED
if not any(delugeweb_auth): if not any(delugeweb_auth):
_get_auth() _get_auth()
try: try:
# content is torrent file contents that needs to be encoded to base64 # content is torrent file contents that needs to be encoded to base64
post_data = json.dumps({"method": "core.add_torrent_file", post_data = json.dumps({"method": "core.add_torrent_file",
"params": [result['name'] + '.torrent', "params": [result['name'] + '.torrent',
b64encode(result['content']).decode(), b64encode(result['content']).decode(), {}],
options],
"id": 2}) "id": 2})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers) verify=deluge_verify_cert, headers=headers)
@@ -515,7 +501,7 @@ def setTorrentLabel(result):
"params": [], "params": [],
"id": 3}) "id": 3})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers) verify=deluge_verify_cert, headers=headers)
labels = json.loads(response.text)['result'] labels = json.loads(response.text)['result']
if labels is not None: if labels is not None:
@@ -526,7 +512,7 @@ def setTorrentLabel(result):
"params": [label], "params": [label],
"id": 4}) "id": 4})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers) verify=deluge_verify_cert, headers=headers)
logger.debug('Deluge: %s label added to Deluge' % label) logger.debug('Deluge: %s label added to Deluge' % label)
except Exception as e: except Exception as e:
logger.error('Deluge: Setting label failed: %s' % str(e)) logger.error('Deluge: Setting label failed: %s' % str(e))
@@ -538,7 +524,7 @@ def setTorrentLabel(result):
"params": [result['hash'], label], "params": [result['hash'], label],
"id": 5}) "id": 5})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers) verify=deluge_verify_cert, headers=headers)
logger.debug('Deluge: %s label added to torrent' % label) logger.debug('Deluge: %s label added to torrent' % label)
else: else:
logger.debug('Deluge: Label plugin not detected') logger.debug('Deluge: Label plugin not detected')
@@ -562,12 +548,12 @@ def setSeedRatio(result):
"params": [result['hash'], True], "params": [result['hash'], True],
"id": 5}) "id": 5})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers) verify=deluge_verify_cert, headers=headers)
post_data = json.dumps({"method": "core.set_torrent_stop_ratio", post_data = json.dumps({"method": "core.set_torrent_stop_ratio",
"params": [result['hash'], float(ratio)], "params": [result['hash'], float(ratio)],
"id": 6}) "id": 6})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth, response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers) verify=deluge_verify_cert, headers=headers)
return not json.loads(response.text)['error'] return not json.loads(response.text)['error']
@@ -579,3 +565,61 @@ def setSeedRatio(result):
return None return None
def setTorrentPath(result):
logger.debug('Deluge: Setting download path')
if not any(delugeweb_auth):
_get_auth()
try:
if headphones.CONFIG.DELUGE_DONE_DIRECTORY or headphones.CONFIG.DOWNLOAD_TORRENT_DIR:
post_data = json.dumps({"method": "core.set_torrent_move_completed",
"params": [result['hash'], True],
"id": 7})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
if headphones.CONFIG.DELUGE_DONE_DIRECTORY:
move_to = headphones.CONFIG.DELUGE_DONE_DIRECTORY
else:
move_to = headphones.CONFIG.DOWNLOAD_TORRENT_DIR
if not os.path.exists(move_to):
logger.debug('Deluge: %s directory doesn\'t exist, let\'s create it' % move_to)
os.makedirs(move_to)
post_data = json.dumps({"method": "core.set_torrent_move_completed_path",
"params": [result['hash'], move_to],
"id": 8})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
return not json.loads(response.text)['error']
return True
except Exception as e:
logger.error('Deluge: Setting torrent move-to directory failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines()
logger.error('; '.join(formatted_lines))
return None
def setTorrentPause(result):
logger.debug('Deluge: Pausing torrent')
if not any(delugeweb_auth):
_get_auth()
try:
if headphones.CONFIG.DELUGE_PAUSED:
post_data = json.dumps({"method": "core.pause_torrent",
"params": [[result['hash']]],
"id": 9})
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
verify=deluge_verify_cert, headers=headers)
return not json.loads(response.text)['error']
return True
except Exception as e:
logger.error('Deluge: Setting torrent paused failed: %s' % str(e))
formatted_lines = traceback.format_exc().splitlines()
logger.error('; '.join(formatted_lines))
return None
+3 -4
View File
@@ -1,6 +1,6 @@
import os.path import os.path
import plistlib import biplist
from headphones import logger from headphones import logger
@@ -14,9 +14,8 @@ def getXldProfile(xldProfile):
# Get xld preferences plist # Get xld preferences plist
try: try:
with open(expanded, 'rb') as _f: preferences = biplist.readPlist(expanded)
preferences = plistlib.load(_f) except (biplist.InvalidPlistException, biplist.NotBinaryPlistException) as e:
except Exception as e:
logger.error("Error reading xld preferences plist: %s", e) logger.error("Error reading xld preferences plist: %s", e)
return (xldProfileNotFound, None, None) return (xldProfileNotFound, None, None)
+40 -58
View File
@@ -14,25 +14,25 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with Headphones. If not, see <http://www.gnu.org/licenses/>. # along with Headphones. If not, see <http://www.gnu.org/licenses/>.
import os from operator import itemgetter
import re import unicodedata
import datetime
import shutil import shutil
import time
import sys import sys
import tempfile import tempfile
import time import glob
import unicodedata
from contextlib import contextmanager
from datetime import datetime, date
from fnmatch import fnmatch
from functools import cmp_to_key
from glob import glob
from operator import itemgetter
from beets import logging as beetslogging from beets import logging as beetslogging
from mediafile import MediaFile, FileTypeError, UnreadableFileError import six
from six import text_type from contextlib import contextmanager
from unidecode import unidecode
import fnmatch
import functools
import re
import os
from mediafile import MediaFile, FileTypeError, UnreadableFileError
from unidecode import unidecode
import headphones import headphones
@@ -42,6 +42,7 @@ RE_FEATURING = re.compile(r"[fF]t\.|[fF]eaturing|[fF]eat\.|\b[wW]ith\b|&|vs\.")
RE_CD_ALBUM = re.compile(r"\(?((CD|disc)\s*[0-9]+)\)?", re.I) RE_CD_ALBUM = re.compile(r"\(?((CD|disc)\s*[0-9]+)\)?", re.I)
RE_CD = re.compile(r"^(CD|dics)\s*[0-9]+$", re.I) RE_CD = re.compile(r"^(CD|dics)\s*[0-9]+$", re.I)
def cmp(x, y): def cmp(x, y):
""" """
Replacement for built-in function cmp that was removed in Python 3 Replacement for built-in function cmp that was removed in Python 3
@@ -52,14 +53,8 @@ def cmp(x, y):
https://portingguide.readthedocs.io/en/latest/comparisons.html#the-cmp-function https://portingguide.readthedocs.io/en/latest/comparisons.html#the-cmp-function
""" """
if x is None and y is None: return (x > y) - (x < y)
return 0
elif x is None:
return -1
elif y is None:
return 1
else:
return (x > y) - (x < y)
def multikeysort(items, columns): def multikeysort(items, columns):
comparers = [ comparers = [
@@ -74,7 +69,7 @@ def multikeysort(items, columns):
else: else:
return 0 return 0
return sorted(items, key=cmp_to_key(comparer)) return sorted(items, key=functools.cmp_to_key(comparer))
def checked(variable): def checked(variable):
@@ -156,25 +151,28 @@ def convert_seconds(s):
def today(): def today():
return date.isoformat(date.today()) today = datetime.date.today()
yyyymmdd = datetime.date.isoformat(today)
return yyyymmdd
def now(): def now():
now = datetime.now() now = datetime.datetime.now()
return now.strftime("%Y-%m-%d %H:%M:%S") return now.strftime("%Y-%m-%d %H:%M:%S")
def is_valid_date(d): def get_age(date):
if not d: try:
split_date = date.split('-')
except:
return False return False
else:
return bool(re.match(r'\d{4}-\d{2}-\d{2}', d))
try:
days_old = int(split_date[0]) * 365 + int(split_date[1]) * 30 + int(split_date[2])
except (IndexError, ValueError):
days_old = False
def age(d): return days_old
'''Requires a valid date'''
delta = date.today() - date.fromisoformat(d)
return delta.days
def bytes_to_mb(bytes): def bytes_to_mb(bytes):
@@ -184,7 +182,7 @@ def bytes_to_mb(bytes):
def mb_to_bytes(mb_str): def mb_to_bytes(mb_str):
result = re.search(r"^(\d+(?:\.\d+)?)\s?(?:mb)?", mb_str, flags=re.I) result = re.search('^(\d+(?:\.\d+)?)\s?(?:mb)?', mb_str, flags=re.I)
if result: if result:
return int(float(result.group(1)) * 1048576) return int(float(result.group(1)) * 1048576)
@@ -236,7 +234,7 @@ def pattern_substitute(pattern, dic, normalize=False):
j = unicodedata.normalize('NFC', j) j = unicodedata.normalize('NFC', j)
except TypeError: except TypeError:
j = unicodedata.normalize('NFC', j = unicodedata.normalize('NFC',
j.decode(headphones.SYS_ENCODING, 'replace')) j.decode(headphones.SYS_ENCODING, 'replace'))
new_dic[i] = j new_dic[i] = j
dic = new_dic dic = new_dic
return pathrender.render(pattern, dic)[0] return pathrender.render(pattern, dic)[0]
@@ -253,9 +251,9 @@ def replace_all(text, dic):
def replace_illegal_chars(string, type="file"): def replace_illegal_chars(string, type="file"):
if type == "file": if type == "file":
string = re.sub(r"[\?\"*:|<>/]", "_", string) string = re.sub('[\?"*:|<>/]', '_', string)
if type == "folder": if type == "folder":
string = re.sub(r"[:\?<>\"|*]", "_", string) string = re.sub('[:\?<>"|*]', '_', string)
return string return string
@@ -281,7 +279,7 @@ _XLATE_GRAPHICAL_AND_DIACRITICAL = {
'Ǥ': 'G', 'ǥ': 'g', 'DZ': 'DZ', 'Dz': 'Dz', 'dz': 'dz', 'Ǥ': 'G', 'ǥ': 'g', 'DZ': 'DZ', 'Dz': 'Dz', 'dz': 'dz',
'Ȥ': 'Z', 'ȥ': 'z', '': 'No.', 'Ȥ': 'Z', 'ȥ': 'z', '': 'No.',
'º': 'o.', # normalize Nº abbrev (popular w/ classical music), 'º': 'o.', # normalize Nº abbrev (popular w/ classical music),
# this is 'masculine ordering indicator', not degree # this is 'masculine ordering indicator', not degree
} }
_XLATE_SPECIAL = { _XLATE_SPECIAL = {
@@ -386,7 +384,7 @@ def clean_musicbrainz_name(s, return_as_string=True):
def cleanTitle(title): def cleanTitle(title):
title = re.sub(r"[\.\-\/\_]", " ", title).lower() title = re.sub('[\.\-\/\_]', ' ', title).lower()
# Strip out extra whitespace # Strip out extra whitespace
title = ' '.join(title.split()) title = ' '.join(title.split())
@@ -506,7 +504,7 @@ def path_match_patterns(path, patterns):
""" """
for pattern in patterns: for pattern in patterns:
if fnmatch(path, pattern): if fnmatch.fnmatch(path, pattern):
return True return True
# No match # No match
@@ -712,7 +710,7 @@ def preserve_torrent_directory(albumpath, forced=False, single=False):
workdir = os.path.join(tempdir, prefix) workdir = os.path.join(tempdir, prefix)
workdir = re.sub(r'\[', '[[]', workdir) workdir = re.sub(r'\[', '[[]', workdir)
workdir = re.sub(r'(?<!\[)\]', '[]]', workdir) workdir = re.sub(r'(?<!\[)\]', '[]]', workdir)
if len(glob(workdir + '*/')) >= 3: if len(glob.glob(workdir + '*/')) >= 3:
logger.error( logger.error(
"Looks like a temp directory has previously been created " "Looks like a temp directory has previously been created "
"for this albumpath, not continuing " "for this albumpath, not continuing "
@@ -860,8 +858,6 @@ def smartMove(src, dest, delete=True):
try: try:
os.rename(src, os.path.join(source_dir, newfile)) os.rename(src, os.path.join(source_dir, newfile))
filename = newfile filename = newfile
source_path = os.path.join(source_dir, filename)
dest_path = os.path.join(dest, filename)
except Exception as e: except Exception as e:
logger.warn(f"Error renaming {src}: {e}") logger.warn(f"Error renaming {src}: {e}")
break break
@@ -888,7 +884,7 @@ def smartMove(src, dest, delete=True):
shutil.copy(source_path, dest_path) shutil.copy(source_path, dest_path)
return True return True
except Exception as e: except Exception as e:
logger.warn(f"Error copying {filename}: {e}") logger.warn(f"Error copying {filename}: {e}")
def walk_directory(basedir, followlinks=True): def walk_directory(basedir, followlinks=True):
@@ -1033,7 +1029,7 @@ class BeetsLogCapture(beetslogging.Handler):
self.messages = [] self.messages = []
def emit(self, record): def emit(self, record):
self.messages.append(text_type(record.msg)) self.messages.append(six.text_type(record.msg))
@contextmanager @contextmanager
@@ -1045,17 +1041,3 @@ def capture_beets_log(logger='beets'):
yield capture.messages yield capture.messages
finally: finally:
log.removeHandler(capture) log.removeHandler(capture)
def have_pct_have_total(db_artist):
have_tracks = db_artist['HaveTracks'] or 0
total_tracks = db_artist['TotalTracks'] or 0
have_pct = have_tracks / total_tracks if total_tracks else 0
return (have_pct, total_tracks)
def has_token(title, token):
return bool(
re.search(rf'(?:\W|^)+{token}(?:\W|$)+',
title,
re.IGNORECASE | re.UNICODE)
)
+3 -28
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from .unittestcompat import TestCase from .unittestcompat import TestCase
from headphones.helpers import clean_name, is_valid_date, age, has_token from headphones.helpers import clean_name
class HelpersTest(TestCase): class HelpersTest(TestCase):
@@ -14,9 +14,9 @@ class HelpersTest(TestCase):
'Symphonęy Nº9': 'Symphoney No.9', 'Symphonęy Nº9': 'Symphoney No.9',
'ÆæßðÞIJij': 'AeaessdThIJıj', 'ÆæßðÞIJij': 'AeaessdThIJıj',
'Obsessió (Cerebral Apoplexy remix)': 'obsessio cerebral ' 'Obsessió (Cerebral Apoplexy remix)': 'obsessio cerebral '
'apoplexy remix', 'apoplexy remix',
'Doktór Hałabała i siedmiu zbojów': 'doktor halabala i siedmiu ' 'Doktór Hałabała i siedmiu zbojów': 'doktor halabala i siedmiu '
'zbojow', 'zbojow',
'Arbetets Söner och Döttrar': 'arbetets soner och dottrar', 'Arbetets Söner och Döttrar': 'arbetets soner och dottrar',
'Björk Guðmundsdóttir': 'bjork gudmundsdottir', 'Björk Guðmundsdóttir': 'bjork gudmundsdottir',
'L\'Arc~en~Ciel': 'larc en ciel', 'L\'Arc~en~Ciel': 'larc en ciel',
@@ -46,28 +46,3 @@ class HelpersTest(TestCase):
self.assertEqual( self.assertEqual(
test, expected, "check clean_name() with narrow non-ascii input" test, expected, "check clean_name() with narrow non-ascii input"
) )
def test_is_valid_date(date):
test_cases = [
('2021-11-12', True, "check is_valid_date returns True for valid date"),
(None, False, "check is_valid_date returns False for None"),
('2021-11', False, "check is_valid_date returns False for incomplete"),
('2021', False, "check is_valid_date returns False for incomplete")
]
for input, expected, desc in test_cases:
self.assertEqual(is_valid_date(input), expected, desc)
def test_has_token(self):
"""helpers: has_token()"""
self.assertEqual(
has_token("a cat ran", "cat"),
True,
"return True if token is in string"
)
self.assertEqual(
has_token("acatran", "cat"),
False,
"return False if token is part of another word"
)
+21 -11
View File
@@ -39,7 +39,7 @@ def is_exists(artistid):
if any(artistid in x for x in artistlist): if any(artistid in x for x in artistlist):
logger.info(artistlist[0][ logger.info(artistlist[0][
1] + " is already in the database. Updating 'have tracks', but not artist information") 1] + " is already in the database. Updating 'have tracks', but not artist information")
return True return True
else: else:
return False return False
@@ -102,7 +102,12 @@ def artistlist_to_mbids(artistlist, forced=False):
myDB.action('DELETE from newartists WHERE ArtistName=?', [artist]) myDB.action('DELETE from newartists WHERE ArtistName=?', [artist])
# Update the similar artist tag cloud: # Update the similar artist tag cloud:
lastfm.getSimilar() logger.info('Updating artist information from Last.fm')
try:
lastfm.getSimilar()
except Exception as e:
logger.warn('Failed to update artist information from Last.fm: %s' % e)
def addArtistIDListToDB(artistidlist): def addArtistIDListToDB(artistidlist):
@@ -240,7 +245,7 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
rgid = rg['id'] rgid = rg['id']
skip_log = 0 skip_log = 0
# Make a user configurable variable to skip update of albums with release dates older than this date (in days) # Make a user configurable variable to skip update of albums with release dates older than this date (in days)
ignore_age = headphones.CONFIG.MB_IGNORE_AGE pause_delta = headphones.CONFIG.MB_IGNORE_AGE
rg_exists = myDB.action("SELECT * from albums WHERE AlbumID=?", [rg['id']]).fetchone() rg_exists = myDB.action("SELECT * from albums WHERE AlbumID=?", [rg['id']]).fetchone()
@@ -269,18 +274,18 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
if len(check_release_date) == 10: if len(check_release_date) == 10:
release_date = check_release_date release_date = check_release_date
elif len(check_release_date) == 7: elif len(check_release_date) == 7:
release_date = check_release_date + "-27" release_date = check_release_date + "-31"
elif len(check_release_date) == 4: elif len(check_release_date) == 4:
release_date = check_release_date + "-12-27" release_date = check_release_date + "-12-31"
else: else:
release_date = today release_date = today
if helpers.age(release_date) < ignore_age: if helpers.get_age(today) - helpers.get_age(release_date) < pause_delta:
logger.info("[%s] Now updating: %s (Release Date <%s Days)", logger.info("[%s] Now updating: %s (Release Date <%s Days)",
artist['artist_name'], rg['title'], ignore_age) artist['artist_name'], rg['title'], pause_delta)
new_releases = mb.get_new_releases(rgid, includeExtras, True) new_releases = mb.get_new_releases(rgid, includeExtras, True)
else: else:
logger.info("[%s] Skipping: %s (Release Date >%s Days)", logger.info("[%s] Skipping: %s (Release Date >%s Days)",
artist['artist_name'], rg['title'], ignore_age) artist['artist_name'], rg['title'], pause_delta)
skip_log = 1 skip_log = 1
new_releases = 0 new_releases = 0
@@ -445,9 +450,14 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
if headphones.CONFIG.AUTOWANT_ALL: if headphones.CONFIG.AUTOWANT_ALL:
newValueDict['Status'] = "Wanted" newValueDict['Status'] = "Wanted"
elif headphones.CONFIG.AUTOWANT_UPCOMING: elif album['ReleaseDate'] > today and headphones.CONFIG.AUTOWANT_UPCOMING:
if helpers.is_valid_date(album['ReleaseDate']) and helpers.age(album['ReleaseDate']) < 21: newValueDict['Status'] = "Wanted"
newValueDict['Status'] = "Wanted" # Sometimes "new" albums are added to musicbrainz after their release date, so let's try to catch these
# The first test just makes sure we have year-month-day
elif helpers.get_age(album['ReleaseDate']) and helpers.get_age(
today) - helpers.get_age(
album['ReleaseDate']) < 21 and headphones.CONFIG.AUTOWANT_UPCOMING:
newValueDict['Status'] = "Wanted"
else: else:
newValueDict['Status'] = "Skipped" newValueDict['Status'] = "Skipped"
+19 -26
View File
@@ -23,7 +23,7 @@ from headphones import db, logger, request
TIMEOUT = 60.0 # seconds TIMEOUT = 60.0 # seconds
REQUEST_LIMIT = 1.0 / 5 # seconds REQUEST_LIMIT = 1.0 / 5 # seconds
ENTRY_POINT = "https://ws.audioscrobbler.com/2.0/" ENTRY_POINT = "https://ws.audioscrobbler.com/2.0/"
APP_API_KEY = "395e6ec6bb557382fc41fde867bce66f" API_KEY = "395e6ec6bb557382fc41fde867bce66f"
# Required for API request limit # Required for API request limit
lastfm_lock = headphones.lock.TimedLock(REQUEST_LIMIT) lastfm_lock = headphones.lock.TimedLock(REQUEST_LIMIT)
@@ -31,7 +31,7 @@ lastfm_lock = headphones.lock.TimedLock(REQUEST_LIMIT)
def request_lastfm(method, **kwargs): def request_lastfm(method, **kwargs):
""" """
Call a Last.fm API method. Automatically sets the method and API key. Method Call a Last.FM API method. Automatically sets the method and API key. Method
will return the result if no error occured. will return the result if no error occured.
By default, this method will request the JSON format, since it is more By default, this method will request the JSON format, since it is more
@@ -40,42 +40,35 @@ def request_lastfm(method, **kwargs):
# Prepare request # Prepare request
kwargs["method"] = method kwargs["method"] = method
kwargs.setdefault("api_key", headphones.CONFIG.LASTFM_APIKEY or APP_API_KEY) kwargs.setdefault("api_key", API_KEY)
kwargs.setdefault("format", "json") kwargs.setdefault("format", "json")
# Send request # Send request
logger.debug("Calling Last.fm method: %s", method) logger.debug("Calling Last.FM method: %s", method)
logger.debug("Last.fm call parameters: %s", kwargs) logger.debug("Last.FM call parameters: %s", kwargs)
data = request.request_json(ENTRY_POINT, timeout=TIMEOUT, params=kwargs, lock=lastfm_lock) data = request.request_json(ENTRY_POINT, timeout=TIMEOUT, params=kwargs, lock=lastfm_lock)
# Parse response and check for errors. # Parse response and check for errors.
if not data: if not data:
logger.error("Error calling Last.fm method: %s", method) logger.error("Error calling Last.FM method: %s", method)
return return
if "error" in data: if "error" in data:
logger.debug("Last.fm returned an error: %s", data["message"]) logger.debug("Last.FM returned an error: %s", data["message"])
return return
return data return data
def getSimilar(): def getSimilar():
if not headphones.CONFIG.LASTFM_APIKEY:
logger.info(
'To update the Similar Artists cloud tag, create a Last.fm application api key '
'and add it under the Advanced config tab'
)
return
myDB = db.DBConnection() myDB = db.DBConnection()
results = myDB.select("SELECT ArtistID from artists ORDER BY HaveTracks DESC LIMIT 10") results = myDB.select("SELECT ArtistID from artists ORDER BY HaveTracks DESC")
logger.info("Fetching similar artists from Last.fm for tag cloud") logger.info("Fetching similar artists from Last.FM for tag cloud")
artistlist = [] artistlist = []
for result in results: for result in results[:12]:
data = request_lastfm("artist.getsimilar", mbid=result["ArtistId"]) data = request_lastfm("artist.getsimilar", mbid=result["ArtistId"])
if data and "similarartists" in data: if data and "similarartists" in data:
@@ -92,7 +85,7 @@ def getSimilar():
artistlist.append((artist_name, artist_mbid)) artistlist.append((artist_name, artist_mbid))
# Add new artists to tag cloud # Add new artists to tag cloud
logger.debug("Fetched %d artists from Last.fm", len(artistlist)) logger.debug("Fetched %d artists from Last.FM", len(artistlist))
count = defaultdict(int) count = defaultdict(int)
for artist, mbid in artistlist: for artist, mbid in artistlist:
@@ -110,7 +103,7 @@ def getSimilar():
myDB.action("INSERT INTO lastfmcloud VALUES( ?, ?, ?)", [artist_name, artist_mbid, count]) myDB.action("INSERT INTO lastfmcloud VALUES( ?, ?, ?)", [artist_name, artist_mbid, count])
logger.debug("Inserted %d artists into Last.fm tag cloud", len(top_list)) logger.debug("Inserted %d artists into Last.FM tag cloud", len(top_list))
def getArtists(): def getArtists():
@@ -118,16 +111,16 @@ def getArtists():
results = myDB.select("SELECT ArtistID from artists") results = myDB.select("SELECT ArtistID from artists")
if not headphones.CONFIG.LASTFM_USERNAME: if not headphones.CONFIG.LASTFM_USERNAME:
logger.warn("Last.fm username not set, not importing artists.") logger.warn("Last.FM username not set, not importing artists.")
return return
logger.info("Fetching artists from Last.fm for username: %s", headphones.CONFIG.LASTFM_USERNAME) logger.info("Fetching artists from Last.FM for username: %s", headphones.CONFIG.LASTFM_USERNAME)
data = request_lastfm("library.getartists", limit=1000, user=headphones.CONFIG.LASTFM_USERNAME) data = request_lastfm("library.getartists", limit=1000, user=headphones.CONFIG.LASTFM_USERNAME)
if data and "artists" in data: if data and "artists" in data:
artistlist = [] artistlist = []
artists = data["artists"]["artist"] artists = data["artists"]["artist"]
logger.debug("Fetched %d artists from Last.fm", len(artists)) logger.debug("Fetched %d artists from Last.FM", len(artists))
for artist in artists: for artist in artists:
artist_mbid = artist["mbid"] artist_mbid = artist["mbid"]
@@ -140,20 +133,20 @@ def getArtists():
for artistid in artistlist: for artistid in artistlist:
importer.addArtisttoDB(artistid) importer.addArtisttoDB(artistid)
logger.info("Imported %d new artists from Last.fm", len(artistlist)) logger.info("Imported %d new artists from Last.FM", len(artistlist))
def getTagTopArtists(tag, limit=50): def getTagTopArtists(tag, limit=50):
myDB = db.DBConnection() myDB = db.DBConnection()
results = myDB.select("SELECT ArtistID from artists") results = myDB.select("SELECT ArtistID from artists")
logger.info("Fetching top artists from Last.fm for tag: %s", tag) logger.info("Fetching top artists from Last.FM for tag: %s", tag)
data = request_lastfm("tag.gettopartists", limit=limit, tag=tag) data = request_lastfm("tag.gettopartists", limit=limit, tag=tag)
if data and "topartists" in data: if data and "topartists" in data:
artistlist = [] artistlist = []
artists = data["topartists"]["artist"] artists = data["topartists"]["artist"]
logger.debug("Fetched %d artists from Last.fm", len(artists)) logger.debug("Fetched %d artists from Last.FM", len(artists))
for artist in artists: for artist in artists:
try: try:
@@ -169,4 +162,4 @@ def getTagTopArtists(tag, limit=50):
for artistid in artistlist: for artistid in artistlist:
importer.addArtisttoDB(artistid) importer.addArtisttoDB(artistid)
logger.debug("Added %d new artists from Last.fm", len(artistlist)) logger.debug("Added %d new artists from Last.FM", len(artistlist))
+13 -16
View File
@@ -77,9 +77,9 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
if track['ArtistName']: if track['ArtistName']:
# Make sure deleted files get accounted for when updating artist track counts # Make sure deleted files get accounted for when updating artist track counts
new_artists.append(track['ArtistName']) new_artists.append(track['ArtistName'])
myDB.action('DELETE FROM have WHERE Location=?', [track['Location']]) myDB.action('DELETE FROM have WHERE Location=?', [Track['Location']])
logger.info( logger.info(
f"{track['Location']} removed from Headphones, as it " f"{Track['Location']} removed from Headphones, as it "
f"is no longer on disk" f"is no longer on disk"
) )
@@ -152,7 +152,7 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
# track_list.append(track_dict) # track_list.append(track_dict)
check_exist_track = myDB.action("SELECT * FROM have WHERE Location=?", check_exist_track = myDB.action("SELECT * FROM have WHERE Location=?",
[track_path]).fetchone() [track_path]).fetchone()
# Only attempt to match tracks that are new, haven't yet been matched, or metadata has changed. # Only attempt to match tracks that are new, haven't yet been matched, or metadata has changed.
if not check_exist_track: if not check_exist_track:
# This is a new track # This is a new track
@@ -167,7 +167,7 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
if f_artist and f_artist != check_exist_track['ArtistName']: if f_artist and f_artist != check_exist_track['ArtistName']:
new_artists.append(f_artist) new_artists.append(f_artist)
elif f_artist and f_artist == check_exist_track['ArtistName'] and \ elif f_artist and f_artist == check_exist_track['ArtistName'] and \
check_exist_track['Matched'] != "Ignored": check_exist_track['Matched'] != "Ignored":
new_artists.append(f_artist) new_artists.append(f_artist)
else: else:
continue continue
@@ -191,26 +191,23 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
# Now we start track matching # Now we start track matching
logger.info(f"{new_track_count} new/modified tracks found and added to the database") logger.info(f"{new_track_count} new/modified tracks found and added to the database")
dbtracks = myDB.action( dbtracks = myDB.action(
"SELECT * FROM have WHERE Matched IS NULL AND LOCATION LIKE ?", "SELECT * FROM have WHERE Matched IS NULL AND LOCATION LIKE ?",
[f"{dir}%"] [f"{dir}%"]
) )
dbtracks_count = myDB.action( dbtracks_count = myDB.action(
"SELECT COUNT(*) FROM have WHERE Matched IS NULL AND LOCATION LIKE ?", "SELECT COUNT(*) FROM have WHERE Matched IS NULL AND LOCATION LIKE ?",
[f"{dir}%"] [f"{dir}%"]
).fetchone()[0] ).fetchone()[0]
logger.info(f"Found {dbtracks_count} new/modified tracks in `{dir}`") logger.info(f"Found {dbtracks_count} new/modified tracks in `{dir}`")
logger.info("Matching tracks to the appropriate releases....") logger.info("Matching tracks to the appropriate releases....")
# Sort the track_list by most vague (e.g. no trackid or releaseid) # Sort the track_list by most vague (e.g. no trackid or releaseid)
# to most specific (both trackid & releaseid) # to most specific (both trackid & releaseid)
# When we insert into the database, the tracks with the most # When we insert into the database, the tracks with the most
# specific information will overwrite the more general matches # specific information will overwrite the more general matches
sorted_dbtracks = helpers.multikeysort(dbtracks, ['ArtistName', 'AlbumTitle']) sorted_dbtracks = helpers.multikeysort(dbtracks, ['ArtistName', 'AlbumTitle'])
# We'll use this to give a % completion, just because the # We'll use this to give a % completion, just because the
# track matching might take a while # track matching might take a while
tracks_completed = 0 tracks_completed = 0
@@ -227,8 +224,8 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
tracks_completed += 1 tracks_completed += 1
completion_percentage = math.floor( completion_percentage = math.floor(
float(tracks_completed) / dbtracks_count * 1000 float(tracks_completed) / dbtracks_count * 1000
) / 10 ) / 10
if completion_percentage >= (last_completion_percentage + 10): if completion_percentage >= (last_completion_percentage + 10):
logger.info("Track matching is " + str(completion_percentage) + "% complete") logger.info("Track matching is " + str(completion_percentage) + "% complete")
+13 -7
View File
@@ -14,14 +14,20 @@
# along with Headphones. If not, see <http://www.gnu.org/licenses/>. # along with Headphones. If not, see <http://www.gnu.org/licenses/>.
from collections import OrderedDict
import musicbrainzngs
import headphones
import headphones.lock
from headphones import logger, db, helpers from headphones import logger, db, helpers
import headphones
import musicbrainzngs
import headphones.lock
try:
# pylint:disable=E0611
# ignore this error because we are catching the ImportError
from collections import OrderedDict
# pylint:enable=E0611
except ImportError:
# Python 2.6.x fallback, from libs
from ordereddict import OrderedDict
mb_lock = headphones.lock.TimedLock(0) mb_lock = headphones.lock.TimedLock(0)
@@ -91,7 +97,7 @@ def findArtist(name, limit=1):
try: try:
artistResults = musicbrainzngs.search_artists(limit=limit, **criteria)['artist-list'] artistResults = musicbrainzngs.search_artists(limit=limit, **criteria)['artist-list']
except ValueError as e: except ValueError as e:
if "at least one query term is required" in str(e): if "at least one query term is required" in e.message:
logger.error( logger.error(
"Tried to search without a term, or an empty one. Provided artist (probably emtpy): %s", "Tried to search without a term, or an empty one. Provided artist (probably emtpy): %s",
name) name)
+3 -10
View File
@@ -38,6 +38,7 @@ class MetadataDict(dict):
lowercase) in member variable self._lower. If case-sensitive lookup lowercase) in member variable self._lower. If case-sensitive lookup
fails, another case-insensitive attempt is made. fails, another case-insensitive attempt is made.
""" """
def __setitem__(self, key, value): def __setitem__(self, key, value):
super(MetadataDict, self).__setitem__(key, value) super(MetadataDict, self).__setitem__(key, value)
self._lower.__setitem__(key.lower(), value) self._lower.__setitem__(key.lower(), value)
@@ -79,7 +80,6 @@ class Vars:
Metadata $variable names (only ones set explicitly by headphones). Metadata $variable names (only ones set explicitly by headphones).
""" """
DISC = '$Disc' DISC = '$Disc'
DISC_TOTAL = '$DiscTotal'
TRACK = '$Track' TRACK = '$Track'
TITLE = '$Title' TITLE = '$Title'
ARTIST = '$Artist' ARTIST = '$Artist'
@@ -172,7 +172,7 @@ def _lower(s):
return None return None
def file_metadata(path, release, single_disc_ignore=False): def file_metadata(path, release):
# type: (str,sqlite3.Row)->Tuple[Mapping[str,str],bool] # type: (str,sqlite3.Row)->Tuple[Mapping[str,str],bool]
""" """
Prepare metadata dictionary for path substitution, based on file name, Prepare metadata dictionary for path substitution, based on file name,
@@ -195,13 +195,7 @@ def file_metadata(path, release, single_disc_ignore=False):
_row_to_dict(release, res) _row_to_dict(release, res)
date, year = _date_year(release) date, year = _date_year(release)
if not f.disc:
if not f.disctotal or (f.disctotal == 1 and single_disc_ignore):
disc_total = ''
else:
disc_total = '%d' % f.disctotal
if not f.disc or (f.disctotal == 1 and single_disc_ignore):
disc_number = '' disc_number = ''
else: else:
disc_number = '%d' % f.disc disc_number = '%d' % f.disc
@@ -233,7 +227,6 @@ def file_metadata(path, release, single_disc_ignore=False):
album_title = release['AlbumTitle'] album_title = release['AlbumTitle']
override_values = { override_values = {
Vars.DISC: disc_number, Vars.DISC: disc_number,
Vars.DISC_TOTAL: disc_total,
Vars.TRACK: track_number, Vars.TRACK: track_number,
Vars.TITLE: title, Vars.TITLE: title,
Vars.ARTIST: artist_name, Vars.ARTIST: artist_name,
+1
View File
@@ -30,6 +30,7 @@ from . import getXldProfile
def encode(albumPath): def encode(albumPath):
print(albumPath)
use_xld = headphones.CONFIG.ENCODER == 'xld' use_xld = headphones.CONFIG.ENCODER == 'xld'
# Return if xld details not found # Return if xld details not found
+19 -15
View File
@@ -1,5 +1,7 @@
from urllib.parse import urlencode, quote_plus from urllib.parse import urlencode, quote_plus
import urllib.request, urllib.parse, urllib.error import urllib.request
import urllib.parse
import urllib.error
import subprocess import subprocess
import json import json
from email.mime.text import MIMEText from email.mime.text import MIMEText
@@ -7,7 +9,9 @@ import smtplib
import email.utils import email.utils
from http.client import HTTPSConnection from http.client import HTTPSConnection
from urllib.parse import parse_qsl from urllib.parse import parse_qsl
import urllib.request, urllib.error, urllib.parse import urllib.request
import urllib.error
import urllib.parse
import requests as requests import requests as requests
import os.path import os.path
@@ -17,7 +21,7 @@ import cherrypy
import headphones import headphones
import gntp.notifier import gntp.notifier
#import oauth2 as oauth #import oauth2 as oauth
import twitter import twitter
class GROWL(object): class GROWL(object):
@@ -246,7 +250,7 @@ class XBMC(object):
if version < 12: # Eden if version < 12: # Eden
notification = header + "," + message + "," + time + \ notification = header + "," + message + "," + time + \
"," + albumartpath "," + albumartpath
notifycommand = {'command': 'ExecBuiltIn', notifycommand = {'command': 'ExecBuiltIn',
'parameter': 'Notification(' + 'parameter': 'Notification(' +
notification + ')'} notification + ')'}
@@ -440,7 +444,7 @@ class Plex(object):
if version < 12: # Eden if version < 12: # Eden
notification = header + "," + message + "," + time + \ notification = header + "," + message + "," + time + \
"," + albumartpath "," + albumartpath
notifycommand = {'command': 'ExecBuiltIn', notifycommand = {'command': 'ExecBuiltIn',
'parameter': 'Notification(' + 'parameter': 'Notification(' +
notification + ')'} notification + ')'}
@@ -604,12 +608,12 @@ class JOIN(object):
self.url += '&deviceId={deviceid}' self.url += '&deviceId={deviceid}'
response = urllib.request.urlopen(self.url.format(apikey=self.apikey, response = urllib.request.urlopen(self.url.format(apikey=self.apikey,
title=quote_plus(event), title=quote_plus(event),
text=quote_plus( text=quote_plus(
message.encode( message.encode(
"utf-8")), "utf-8")),
icon=icon, icon=icon,
deviceid=self.deviceid)) deviceid=self.deviceid))
if response: if response:
logger.info("Join notifications sent.") logger.info("Join notifications sent.")
@@ -733,8 +737,8 @@ class TwitterNotifier(object):
def notify_download(self, title): def notify_download(self, title):
if headphones.CONFIG.TWITTER_ENABLED: if headphones.CONFIG.TWITTER_ENABLED:
self._notifyTwitter(common.notifyStrings[ self._notifyTwitter(common.notifyStrings[
common.NOTIFY_DOWNLOAD] + ': ' + common.NOTIFY_DOWNLOAD] + ': ' +
title + ' at ' + helpers.now()) title + ' at ' + helpers.now())
def test_notify(self): def test_notify(self):
return self._notifyTwitter( return self._notifyTwitter(
@@ -798,7 +802,7 @@ class TwitterNotifier(object):
if resp['status'] != '200': if resp['status'] != '200':
logger.info('The request for a token with did not succeed: ' + str( logger.info('The request for a token with did not succeed: ' + str(
resp['status']), resp['status']),
logger.ERROR) logger.ERROR)
return False return False
else: else:
logger.info('Your Twitter Access Token key: %s' % access_token[ logger.info('Your Twitter Access Token key: %s' % access_token[
@@ -1020,7 +1024,7 @@ class TELEGRAM(object):
# MusicBrainz link # MusicBrainz link
if rgid: if rgid:
message += '\n\n <a href="https://musicbrainz.org/' \ message += '\n\n <a href="https://musicbrainz.org/' \
'release-group/%s">MusicBrainz</a>' % rgid 'release-group/%s">MusicBrainz</a>' % rgid
# Send image # Send image
response = None response = None
+1 -2
View File
@@ -70,8 +70,7 @@ def sendNZB(nzb):
nzbcontent64 = None nzbcontent64 = None
if nzb.resultType == "nzbdata": if nzb.resultType == "nzbdata":
data = nzb.extraInfo[0] data = nzb.extraInfo[0]
# NZBGet needs a string, not bytes nzbcontent64 = standard_b64encode(data)
nzbcontent64 = standard_b64encode(data).decode("utf-8")
logger.info("Sending NZB to NZBget") logger.info("Sending NZB to NZBget")
logger.debug("URL: " + url) logger.debug("URL: " + url)
+3
View File
@@ -38,6 +38,7 @@ __author__ = "Andrzej Ciarkowski <andrzej.ciarkowski@gmail.com>"
class _PatternElement(object): class _PatternElement(object):
'''ABC for hierarchy of path name renderer pattern elements.''' '''ABC for hierarchy of path name renderer pattern elements.'''
def render(self, replacement): def render(self, replacement):
# type: (Mapping[str,str]) -> str # type: (Mapping[str,str]) -> str
'''Format this _PatternElement into string using provided substitution dictionary.''' '''Format this _PatternElement into string using provided substitution dictionary.'''
@@ -55,6 +56,7 @@ class _Generator(_PatternElement):
class _Replacement(_Generator): class _Replacement(_Generator):
'''Replacement variable, eg. $title.''' '''Replacement variable, eg. $title.'''
def __init__(self, pattern): def __init__(self, pattern):
# type: (str) # type: (str)
self._pattern = pattern self._pattern = pattern
@@ -81,6 +83,7 @@ class _Replacement(_Generator):
class _LiteralText(_PatternElement): class _LiteralText(_PatternElement):
'''Just a plain piece of text to be rendered "as is".''' '''Just a plain piece of text to be rendered "as is".'''
def __init__(self, text): def __init__(self, text):
# type: (str) # type: (str)
self._text = text self._text = text
+22 -55
View File
@@ -27,7 +27,7 @@ from beets import config as beetsconfig
from beets import logging as beetslogging from beets import logging as beetslogging
from mediafile import MediaFile, FileTypeError, UnreadableFileError from mediafile import MediaFile, FileTypeError, UnreadableFileError
from beetsplug import lyrics as beetslyrics from beetsplug import lyrics as beetslyrics
from headphones import notifiers, utorrent, transmission, deluge, qbittorrent, soulseek from headphones import notifiers, utorrent, transmission, deluge, qbittorrent
from headphones import db, albumart, librarysync from headphones import db, albumart, librarysync
from headphones import logger, helpers, mb, music_encoder from headphones import logger, helpers, mb, music_encoder
from headphones import metadata from headphones import metadata
@@ -36,45 +36,18 @@ postprocessor_lock = threading.Lock()
def checkFolder(): def checkFolder():
logger.info("Checking download folder for completed downloads (only snatched ones).") logger.debug("Checking download folder for completed downloads (only snatched ones).")
with postprocessor_lock: with postprocessor_lock:
myDB = db.DBConnection() myDB = db.DBConnection()
snatched = myDB.select('SELECT * from snatched WHERE Status="Snatched"') snatched = myDB.select('SELECT * from snatched WHERE Status="Snatched"')
for album in snatched: for album in snatched:
if album['FolderName']: if album['FolderName']:
folder_name = album['FolderName'] folder_name = album['FolderName']
single = False single = False
if album['Kind'] == 'nzb':
# Soulseek, check download complete or errored download_dir = headphones.CONFIG.DOWNLOAD_DIR
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: else:
if headphones.CONFIG.DELUGE_DONE_DIRECTORY and headphones.CONFIG.TORRENT_DOWNLOADER == 3: if headphones.CONFIG.DELUGE_DONE_DIRECTORY and headphones.CONFIG.TORRENT_DOWNLOADER == 3:
download_dir = headphones.CONFIG.DELUGE_DONE_DIRECTORY download_dir = headphones.CONFIG.DELUGE_DONE_DIRECTORY
@@ -92,6 +65,7 @@ def checkFolder():
folder_name = torrent_folder_name folder_name = torrent_folder_name
if folder_name: if folder_name:
print(folder_name)
album_path = os.path.join(download_dir, folder_name) album_path = os.path.join(download_dir, folder_name)
logger.debug("Checking if %s exists" % album_path) logger.debug("Checking if %s exists" % album_path)
@@ -106,6 +80,7 @@ def checkFolder():
def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=False, single=False): def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=False, single=False):
print(albumpath)
myDB = db.DBConnection() myDB = db.DBConnection()
release = myDB.action('SELECT * from albums WHERE AlbumID=?', [albumid]).fetchone() release = myDB.action('SELECT * from albums WHERE AlbumID=?', [albumid]).fetchone()
tracks = myDB.select('SELECT * from tracks WHERE AlbumID=?', [albumid]) tracks = myDB.select('SELECT * from tracks WHERE AlbumID=?', [albumid])
@@ -316,7 +291,7 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
logger.debug('Metadata check failed. Verifying filenames...') logger.debug('Metadata check failed. Verifying filenames...')
for downloaded_track in downloaded_track_list: for downloaded_track in downloaded_track_list:
track_name = os.path.splitext(downloaded_track)[0] track_name = os.path.splitext(downloaded_track)[0]
split_track_name = re.sub(r'[\.\-\_]', r' ', track_name).lower() split_track_name = re.sub('[\.\-\_]', ' ', track_name).lower()
for track in tracks: for track in tracks:
if not track['TrackTitle']: if not track['TrackTitle']:
@@ -367,6 +342,7 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
logger.warn(f"Could not identify {albumpath}. It may not be the intended album") logger.warn(f"Could not identify {albumpath}. It may not be the intended album")
markAsUnprocessed(albumid, albumpath, keep_original_folder) markAsUnprocessed(albumid, albumpath, keep_original_folder)
def markAsUnprocessed(albumid, albumpath, keep_original_folder=False): def markAsUnprocessed(albumid, albumpath, keep_original_folder=False):
myDB = db.DBConnection() myDB = db.DBConnection()
myDB.action( myDB.action(
@@ -444,7 +420,7 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
logger.debug("Write check exact error: %s", e) logger.debug("Write check exact error: %s", e)
logger.error( logger.error(
f"`{downloaded_track}` is not writable. This is required " f"`{downloaded_track}` is not writable. This is required "
"for some post processing steps. Not continuing." "for some post processing steps. Not continuing."
) )
if new_folder: if new_folder:
shutil.rmtree(new_folder) shutil.rmtree(new_folder)
@@ -620,7 +596,7 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
logger.info("Twitter notifications temporarily disabled") logger.info("Twitter notifications temporarily disabled")
#logger.info("Sending Twitter notification") #logger.info("Sending Twitter notification")
#twitter = notifiers.TwitterNotifier() #twitter = notifiers.TwitterNotifier()
#twitter.notify_download(pushmessage) # twitter.notify_download(pushmessage)
if headphones.CONFIG.OSX_NOTIFY_ENABLED: if headphones.CONFIG.OSX_NOTIFY_ENABLED:
from headphones import cache from headphones import cache
@@ -811,7 +787,7 @@ def moveFiles(albumpath, release, metadata_dict):
newfolder = temp_folder + '[%i]' % i newfolder = temp_folder + '[%i]' % i
lossless_destination_path = os.path.normpath( lossless_destination_path = os.path.normpath(
os.path.join( os.path.join(
headphones.CONFIG.LOSSLESS_DESTINATION_DIR, headphones.CONFIG.LOSSLESS_DESTINATION_DIR,
newfolder newfolder
) )
) )
@@ -853,7 +829,7 @@ def moveFiles(albumpath, release, metadata_dict):
newfolder = temp_folder + '[%i]' % i newfolder = temp_folder + '[%i]' % i
lossy_destination_path = os.path.normpath( lossy_destination_path = os.path.normpath(
os.path.join( os.path.join(
headphones.CONFIG.DESTINATION_DIR, headphones.CONFIG.DESTINATION_DIR,
newfolder newfolder
) )
) )
@@ -902,7 +878,7 @@ def moveFiles(albumpath, release, metadata_dict):
os.remove(file_to_move) os.remove(file_to_move)
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error deleting `{file_to_move}` from source directory") f"Error deleting `{file_to_move}` from source directory")
else: else:
logger.error( logger.error(
f"Error copying `{file_to_move}`. " f"Error copying `{file_to_move}`. "
@@ -1112,11 +1088,7 @@ def renameFiles(albumpath, downloaded_track_list, release):
# Until tagging works better I'm going to rely on the already provided metadata # Until tagging works better I'm going to rely on the already provided metadata
for downloaded_track in downloaded_track_list: for downloaded_track in downloaded_track_list:
md, from_metadata = metadata.file_metadata( md, from_metadata = metadata.file_metadata(downloaded_track, release)
downloaded_track,
release,
headphones.CONFIG.RENAME_SINGLE_DISC_IGNORE
)
if md is None: if md is None:
# unable to parse media file, skip file # unable to parse media file, skip file
continue continue
@@ -1164,6 +1136,7 @@ def updateFilePermissions(albumpaths):
logger.error(f"Could not change permissions for `{full_path}`") logger.error(f"Could not change permissions for `{full_path}`")
continue continue
def renameUnprocessedFolder(path, tag): def renameUnprocessedFolder(path, tag):
""" """
Rename a unprocessed folder to a new unique name to indicate a certain Rename a unprocessed folder to a new unique name to indicate a certain
@@ -1195,15 +1168,10 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
if dir: if dir:
download_dirs.append(dir) download_dirs.append(dir)
else: if headphones.CONFIG.DOWNLOAD_DIR and not dir:
if headphones.CONFIG.DOWNLOAD_DIR: download_dirs.append(headphones.CONFIG.DOWNLOAD_DIR)
download_dirs.append(headphones.CONFIG.DOWNLOAD_DIR) if headphones.CONFIG.DOWNLOAD_TORRENT_DIR and not dir:
if headphones.CONFIG.SOULSEEK_DOWNLOAD_DIR: download_dirs.append(headphones.CONFIG.DOWNLOAD_TORRENT_DIR)
download_dirs.append(headphones.CONFIG.SOULSEEK_DOWNLOAD_DIR)
if headphones.CONFIG.DOWNLOAD_TORRENT_DIR:
download_dirs.append(headphones.CONFIG.DOWNLOAD_TORRENT_DIR)
if headphones.CONFIG.BANDCAMP:
download_dirs.append(headphones.CONFIG.BANDCAMP_DIR)
# If DOWNLOAD_DIR and DOWNLOAD_TORRENT_DIR are the same, remove the duplicate to prevent us from trying to process the same folder twice. # 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)) download_dirs = list(set(download_dirs))
@@ -1213,7 +1181,6 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
folders = [] folders = []
for download_dir in download_dirs: for download_dir in download_dirs:
download_dir = download_dir.encode(headphones.SYS_ENCODING, 'replace')
if not os.path.isdir(download_dir): if not os.path.isdir(download_dir):
logger.warn('Directory %s does not exist. Skipping', download_dir) logger.warn('Directory %s does not exist. Skipping', download_dir)
continue continue
@@ -1231,9 +1198,9 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
subfolders = helpers.expand_subfolders(path_to_folder) subfolders = helpers.expand_subfolders(path_to_folder)
if expand_subfolders and subfolders is not None: if expand_subfolders and subfolders is not None:
folders.extend(subfolders.decode(headphones.SYS_ENCODING, 'replace')) folders.extend(subfolders)
else: else:
folders.append(path_to_folder.decode(headphones.SYS_ENCODING, 'replace')) folders.append(path_to_folder)
# Log number of folders # Log number of folders
if folders: if folders:
+7 -3
View File
@@ -13,8 +13,12 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with Headphones. If not, see <http://www.gnu.org/licenses/>. # along with Headphones. If not, see <http://www.gnu.org/licenses/>.
import urllib.request, urllib.parse, urllib.error import urllib.request
import urllib.request, urllib.error, urllib.parse import urllib.parse
import urllib.error
import urllib.request
import urllib.error
import urllib.parse
import http.cookiejar import http.cookiejar
import json import json
import time import time
@@ -81,7 +85,7 @@ class qbittorrentclient(object):
logger.debug('Error getting SID. qBittorrent responded with error: ' + str(err.reason)) logger.debug('Error getting SID. qBittorrent responded with error: ' + str(err.reason))
return return
for cookie in self.cookiejar: for cookie in self.cookiejar:
logger.debug('login cookie: ' + cookie.name + ', value: ' + cookie.value) logger.debug('login cookie: ' + cookie.name + ', value: ' + cookie.value)
return return
def _command(self, command, args=None, content_type=None, files=None): def _command(self, command, args=None, content_type=None, files=None):
+3 -8
View File
@@ -23,9 +23,6 @@ from headphones import logger
import feedparser import feedparser
import headphones import headphones
import headphones.lock import headphones.lock
from bs4.builder import XMLParsedAsHTMLWarning
import warnings
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
# Disable SSL certificate warnings. We have our own handling # Disable SSL certificate warnings. We have our own handling
@@ -223,7 +220,7 @@ def server_message(response):
# First attempt is to 'read' the response as HTML # First attempt is to 'read' the response as HTML
if response.headers.get("content-type") and \ if response.headers.get("content-type") and \
"text/html" in response.headers.get("content-type"): "text/html" in response.headers.get("content-type"):
try: try:
soup = BeautifulSoup(response.content, "html.parser") soup = BeautifulSoup(response.content, "html.parser")
except Exception: except Exception:
@@ -248,9 +245,7 @@ def server_message(response):
if message: if message:
# Truncate message if it is too long. # Truncate message if it is too long.
if len(message) > 200: if len(message) > 150:
if not type(message) == str: message = message[:150] + "..."
message = message.decode(headphones.SYS_ENCODING, 'replace')
message = message[:200] + "..."
logger.debug("Server responded with message: %s", message) logger.debug("Server responded with message: %s", message)
+9 -17
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env python #!/usr/bin/env python
import urllib.request, urllib.parse, urllib.error import urllib.request
import urllib.parse
import urllib.error
import time import time
from urllib.parse import urlparse from urllib.parse import urlparse
import re import re
@@ -11,7 +13,6 @@ from bs4 import BeautifulSoup
import headphones import headphones
from headphones import logger from headphones import logger
from headphones.types import Result
class Rutracker(object): class Rutracker(object):
@@ -42,22 +43,19 @@ class Rutracker(object):
'login_password': headphones.CONFIG.RUTRACKER_PASSWORD, 'login_password': headphones.CONFIG.RUTRACKER_PASSWORD,
'login': b'\xc2\xf5\xee\xe4' # '%C2%F5%EE%E4' 'login': b'\xc2\xf5\xee\xe4' # '%C2%F5%EE%E4'
} }
headers = {
'User-Agent' : 'Headphones'
}
logger.info("Attempting to log in to rutracker...") logger.info("Attempting to log in to rutracker...")
try: try:
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False, headers=headers) r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False)
# try again # try again
if not self.has_bb_session_cookie(r): if not self.has_bb_session_cookie(r):
time.sleep(10) time.sleep(10)
if headphones.CONFIG.RUTRACKER_COOKIE: if headphones.CONFIG.RUTRACKER_COOKIE:
logger.info("Attempting to log in using predefined cookie...") logger.info("Attempting to log in using predefined cookie...")
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False, headers=headers, cookies={'bb_session': headphones.CONFIG.RUTRACKER_COOKIE}) r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False, cookies={'bb_session': headphones.CONFIG.RUTRACKER_COOKIE})
else: else:
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False, headers=headers) r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False)
if self.has_bb_session_cookie(r): if self.has_bb_session_cookie(r):
self.loggedin = True self.loggedin = True
logger.info("Successfully logged in to rutracker") logger.info("Successfully logged in to rutracker")
@@ -116,10 +114,7 @@ class Rutracker(object):
Parse the search results and return valid torrent list Parse the search results and return valid torrent list
""" """
try: try:
headers = { headers = {'Referer': self.search_referer}
'Referer': self.search_referer,
'User-Agent' : 'Headphones'
}
r = self.session.get(url=searchurl, headers=headers, timeout=self.timeout) r = self.session.get(url=searchurl, headers=headers, timeout=self.timeout)
soup = BeautifulSoup(r.content, 'html.parser') soup = BeautifulSoup(r.content, 'html.parser')
@@ -167,7 +162,7 @@ class Rutracker(object):
torrent_id = dict([part.split('=') for part in urlparse(url)[4].split('&')])[ torrent_id = dict([part.split('=') for part in urlparse(url)[4].split('&')])[
't'] 't']
topicurl = 'https://rutracker.org/forum/viewtopic.php?t=' + torrent_id topicurl = 'https://rutracker.org/forum/viewtopic.php?t=' + torrent_id
rulist.append(Result(title, size, url, 'rutracker.org', 'torrent', True)) rulist.append((title, size, topicurl, 'rutracker.org', 'torrent', True))
else: else:
logger.info("%s is larger than the maxsize or has too little seeders for this category, " logger.info("%s is larger than the maxsize or has too little seeders for this category, "
"skipping. (Size: %i bytes, Seeders: %i)" % (title, size, int(seeds))) "skipping. (Size: %i bytes, Seeders: %i)" % (title, size, int(seeds)))
@@ -189,10 +184,7 @@ class Rutracker(object):
downloadurl = 'https://rutracker.org/forum/dl.php?t=' + torrent_id downloadurl = 'https://rutracker.org/forum/dl.php?t=' + torrent_id
cookie = {'bb_dl': torrent_id} cookie = {'bb_dl': torrent_id}
try: try:
headers = { headers = {'Referer': url}
'Referer': url,
'User-Agent' : 'Headphones'
}
r = self.session.post(url=downloadurl, cookies=cookie, headers=headers, r = self.session.post(url=downloadurl, cookies=cookie, headers=headers,
timeout=self.timeout) timeout=self.timeout)
return r.content return r.content
+1 -1
View File
@@ -30,7 +30,7 @@ def sab_api_call(request_type=None, params={}, **kwargs):
if headphones.CONFIG.SAB_HOST.endswith('/'): if headphones.CONFIG.SAB_HOST.endswith('/'):
headphones.CONFIG.SAB_HOST = headphones.CONFIG.SAB_HOST[ headphones.CONFIG.SAB_HOST = headphones.CONFIG.SAB_HOST[
0:len(headphones.CONFIG.SAB_HOST) - 1] 0:len(headphones.CONFIG.SAB_HOST) - 1]
url = headphones.CONFIG.SAB_HOST + "/" + "api?" url = headphones.CONFIG.SAB_HOST + "/" + "api?"
+603 -674
View File
File diff suppressed because it is too large Load Diff
-252
View File
@@ -1,252 +0,0 @@
from collections import defaultdict, namedtuple
import os
import time
import slskd_api
import headphones
from headphones import logger
from datetime import datetime, timedelta
Result = namedtuple('Result', ['title', 'size', 'user', 'provider', 'type', 'matches', 'bandwidth', 'hasFreeUploadSlot', 'queueLength', 'files', 'kind', 'url', 'folder'])
def initialize_soulseek_client():
host = headphones.CONFIG.SOULSEEK_API_URL
api_key = headphones.CONFIG.SOULSEEK_API_KEY
return slskd_api.SlskdClient(host=host, api_key=api_key)
# Search logic, calling search and processing fucntions
def search(artist, album, year, num_tracks, losslessOnly, allow_lossless, user_search_term):
client = initialize_soulseek_client()
# override search string with user provided search term if entered
if user_search_term:
artist = user_search_term
album = ''
year = ''
# Stage 1: Search with artist, album, year, and num_tracks
logger.info(f"Searching Soulseek using term: {artist} {album} {year}")
results = execute_search(client, artist, album, year, losslessOnly, allow_lossless)
processed_results = process_results(results, losslessOnly, allow_lossless, num_tracks)
if processed_results or user_search_term or album.lower() == artist.lower():
return processed_results
# Stage 2: If Stage 1 fails, search with artist, album, and num_tracks (excluding year)
logger.info("Soulseek search stage 1 did not meet criteria. Retrying without year...")
results = execute_search(client, artist, album, None, losslessOnly, allow_lossless)
processed_results = process_results(results, losslessOnly, allow_lossless, num_tracks)
if processed_results or artist == "Various Artists":
return processed_results
# Stage 3: Final attempt, search only with artist and album
logger.info("Soulseek search stage 2 did not meet criteria. Final attempt with only artist and album.")
results = execute_search(client, artist, album, None, losslessOnly, allow_lossless)
processed_results = process_results(results, losslessOnly, allow_lossless, num_tracks, ignore_track_count=True)
return processed_results
def execute_search(client, artist, album, year, losslessOnly, allow_lossless):
search_text = f"{artist} {album}"
if year:
search_text += f" {year}"
if losslessOnly:
search_text += " flac"
elif not allow_lossless:
search_text += " mp3"
# Actual search
search_response = client.searches.search_text(searchText=search_text, filterResponses=True)
search_id = search_response.get('id')
# Wait for search completion and return response
while not client.searches.state(id=search_id).get('isComplete'):
time.sleep(2)
return client.searches.search_responses(id=search_id)
# Processing the search result passed
def process_results(results, losslessOnly, allow_lossless, num_tracks, ignore_track_count=False):
if losslessOnly:
valid_extensions = {'.flac'}
elif allow_lossless:
valid_extensions = {'.mp3', '.flac'}
else:
valid_extensions = {'.mp3'}
albums = defaultdict(lambda: {'files': [], 'user': None, 'hasFreeUploadSlot': None, 'queueLength': None, 'uploadSpeed': None})
# Extract info from the api response and combine files at album level
for result in results:
user = result.get('username')
hasFreeUploadSlot = result.get('hasFreeUploadSlot')
queueLength = result.get('queueLength')
uploadSpeed = result.get('uploadSpeed')
# Only handle .mp3 and .flac
for file in result.get('files', []):
filename = file.get('filename')
file_extension = os.path.splitext(filename)[1].lower()
if file_extension in valid_extensions:
#album_directory = os.path.dirname(filename)
album_directory = filename.rsplit('\\', 1)[0]
albums[album_directory]['files'].append(file)
# Update metadata only once per album_directory
if albums[album_directory]['user'] is None:
albums[album_directory].update({
'user': user,
'hasFreeUploadSlot': hasFreeUploadSlot,
'queueLength': queueLength,
'uploadSpeed': uploadSpeed,
})
# Filter albums based on num_tracks, add bunch of useful info to the compiled album
final_results = []
for directory, album_data in albums.items():
if ignore_track_count and len(album_data['files']) > 1 or len(album_data['files']) == num_tracks:
#album_title = os.path.basename(directory)
album_title = directory.rsplit('\\', 1)[1]
total_size = sum(file.get('size', 0) for file in album_data['files'])
final_results.append(Result(
title=album_title,
size=int(total_size),
user=album_data['user'],
provider="soulseek",
type="soulseek",
matches=True,
bandwidth=album_data['uploadSpeed'],
hasFreeUploadSlot=album_data['hasFreeUploadSlot'],
queueLength=album_data['queueLength'],
files=album_data['files'],
kind='soulseek',
url='http://' + album_data['user'] + album_title, # URL is needed in other parts of the program.
#folder=os.path.basename(directory)
folder = album_title
))
return final_results
def download(user, filelist):
client = initialize_soulseek_client()
client.transfers.enqueue(username=user, files=filelist)
def download_completed():
client = initialize_soulseek_client()
all_downloads = client.transfers.get_all_downloads(includeRemoved=False)
album_completion_tracker = {} # Tracks completion state of each album's songs
album_errored_tracker = {} # Tracks albums with errored downloads
# Anything older than 24 hours will be canceled
cutoff_time = datetime.now() - timedelta(hours=24)
# Identify errored and completed albums
for download in all_downloads:
directories = download.get('directories', [])
for directory in directories:
album_part = directory.get('directory', '').split('\\')[-1]
files = directory.get('files', [])
for file_data in files:
state = file_data.get('state', '')
requested_at_str = file_data.get('requestedAt', '1900-01-01 00:00:00')
requested_at = parse_datetime(requested_at_str)
# Initialize or update album entry in trackers
if album_part not in album_completion_tracker:
album_completion_tracker[album_part] = {'total': 0, 'completed': 0, 'errored': 0}
if album_part not in album_errored_tracker:
album_errored_tracker[album_part] = False
album_completion_tracker[album_part]['total'] += 1
if 'Completed, Succeeded' in state:
album_completion_tracker[album_part]['completed'] += 1
elif 'Completed, Errored' in state or requested_at < cutoff_time:
album_completion_tracker[album_part]['errored'] += 1
album_errored_tracker[album_part] = True # Mark album as having errored downloads
# Identify errored albums
errored_albums = {album for album, errored in album_errored_tracker.items() if errored}
# Cancel downloads for errored albums
for download in all_downloads:
directories = download.get('directories', [])
for directory in directories:
album_part = directory.get('directory', '').split('\\')[-1]
files = directory.get('files', [])
for file_data in files:
if album_part in errored_albums:
# Extract 'id' and 'username' for each file to cancel the download
file_id = file_data.get('id', '')
username = file_data.get('username', '')
success = client.transfers.cancel_download(username, file_id)
if not success:
logger.debug(f"Soulseek failed to cancel download for file ID: {file_id}")
# Clear completed/canceled/errored stuff from client downloads
try:
client.transfers.remove_completed_downloads()
except Exception as e:
logger.debug(f"Soulseek failed to remove completed downloads: {e}")
# Identify completed albums
completed_albums = {album for album, counts in album_completion_tracker.items() if counts['total'] == counts['completed']}
# Return both completed and errored albums
return completed_albums, errored_albums
def download_completed_album(username, foldername):
client = initialize_soulseek_client()
downloads = client.transfers.get_downloads(username)
# Anything older than 24 hours will be canceled
cutoff_time = datetime.now() - timedelta(hours=24)
total_count = 0
completed_count = 0
errored_count = 0
file_ids = []
# Identify errored and completed album
directories = downloads.get('directories', [])
for directory in directories:
album_part = directory.get('directory', '').split('\\')[-1]
if album_part == foldername:
files = directory.get('files', [])
for file_data in files:
state = file_data.get('state', '')
requested_at_str = file_data.get('requestedAt', '1900-01-01 00:00:00')
requested_at = parse_datetime(requested_at_str)
total_count += 1
file_id = file_data.get('id', '')
file_ids.append(file_id)
if 'Completed, Succeeded' in state:
completed_count += 1
elif 'Completed, Errored' in state or requested_at < cutoff_time:
errored_count += 1
break
completed = True if completed_count == total_count else False
errored = True if errored_count else False
# Cancel downloads for errored album
if errored:
for file_id in file_ids:
try:
success = client.transfers.cancel_download(username, file_id, remove=True)
except Exception as e:
logger.debug(f"Soulseek failed to cancel download for folder with file ID: {foldername} {file_id}")
return completed, errored
def parse_datetime(datetime_string):
# Parse the datetime api response
if '.' in datetime_string:
datetime_string = datetime_string[:datetime_string.index('.')+7]
return datetime.strptime(datetime_string, '%Y-%m-%dT%H:%M:%S.%f')
+8 -7
View File
@@ -15,7 +15,7 @@
import time import time
import json import json
from base64 import b64encode import base64
import urllib.parse import urllib.parse
import os import os
@@ -36,10 +36,10 @@ def addTorrent(link, data=None):
if link.endswith('.torrent') and not link.startswith(('http', 'magnet')) or data: if link.endswith('.torrent') and not link.startswith(('http', 'magnet')) or data:
if data: if data:
metainfo = b64encode(data).decode("utf-8") metainfo = str(base64.b64encode(data))
else: else:
with open(link, 'rb') as f: with open(link, 'rb') as f:
metainfo = b64encode(f.read()).decode("utf-8") metainfo = str(base64.b64encode(f.read()))
arguments = {'metainfo': metainfo, 'download-dir': headphones.CONFIG.DOWNLOAD_TORRENT_DIR} arguments = {'metainfo': metainfo, 'download-dir': headphones.CONFIG.DOWNLOAD_TORRENT_DIR}
else: else:
arguments = {'filename': link, 'download-dir': headphones.CONFIG.DOWNLOAD_TORRENT_DIR} arguments = {'filename': link, 'download-dir': headphones.CONFIG.DOWNLOAD_TORRENT_DIR}
@@ -183,15 +183,15 @@ def torrentAction(method, arguments):
if _session_id is not None: if _session_id is not None:
headers = {'x-transmission-session-id': _session_id} headers = {'x-transmission-session-id': _session_id}
response = request.request_response(host, method="POST", response = request.request_response(host, method="POST",
data=data_json, headers=headers, auth=auth, data=data_json, headers=headers, auth=auth,
whitelist_status_code=[200, 401, 409]) whitelist_status_code=[200, 401, 409])
else: else:
response = request.request_response(host, auth=auth, response = request.request_response(host, auth=auth,
whitelist_status_code=[401, 409]) whitelist_status_code=[401, 409])
if response.status_code == 401: if response.status_code == 401:
if auth: if auth:
logger.error("Username and/or password not accepted by " logger.error("Username and/or password not accepted by "
"Transmission") "Transmission")
else: else:
logger.error("Transmission authorization required") logger.error("Transmission authorization required")
return return
@@ -205,4 +205,5 @@ def torrentAction(method, arguments):
continue continue
resp_json = response.json() resp_json = response.json()
print(resp_json)
return resp_json return resp_json
-10
View File
@@ -1,10 +0,0 @@
from dataclasses import dataclass
@dataclass(frozen=True)
class Result:
title: str
size: int
url: str
provider: str
kind: str
matches: bool
+6 -2
View File
@@ -13,11 +13,15 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with Headphones. If not, see <http://www.gnu.org/licenses/>. # along with Headphones. If not, see <http://www.gnu.org/licenses/>.
import urllib.request, urllib.parse, urllib.error import urllib.request
import urllib.parse
import urllib.error
import json import json
import time import time
from collections import namedtuple from collections import namedtuple
import urllib.request, urllib.error, urllib.parse import urllib.request
import urllib.error
import urllib.parse
import urllib.parse import urllib.parse
import http.cookiejar import http.cookiejar
+81 -78
View File
@@ -15,46 +15,38 @@
# NZBGet support added by CurlyMo <curlymoo1@gmail.com> as a part of XBian - XBMC on the Raspberry Pi # NZBGet support added by CurlyMo <curlymoo1@gmail.com> as a part of XBian - XBMC on the Raspberry Pi
import json
import os
import random
import re
import secrets
import sys
import threading
import time
from collections import OrderedDict
from dataclasses import asdict
from html import escape as html_escape
from operator import itemgetter from operator import itemgetter
from urllib import parse import threading
import secrets
import random
import urllib.request
import urllib.parse
import urllib.error
import json
import time
import sys
from html import escape as html_escape
import urllib.request
import urllib.error
import urllib.parse
import cherrypy import os
from mako import exceptions import re
from headphones import logger, searcher, db, importer, mb, lastfm, librarysync, helpers, notifiers, crier
from headphones.helpers import checked, radio, today, clean_name
from mako.lookup import TemplateLookup from mako.lookup import TemplateLookup
from mako import exceptions
import headphones import headphones
from headphones import ( import cherrypy
crier,
db, try:
importer, # pylint:disable=E0611
lastfm, # ignore this error because we are catching the ImportError
librarysync, from collections import OrderedDict
logger, # pylint:enable=E0611
mb, except ImportError:
notifiers, # Python 2.6.x fallback, from libs
searcher, from ordereddict import OrderedDict
)
from headphones.helpers import (
checked,
clean_name,
have_pct_have_total,
pattern_substitute,
radio,
replace_illegal_chars,
today,
)
from headphones.types import Result
def serve_template(templatename, **kwargs): def serve_template(templatename, **kwargs):
@@ -338,9 +330,9 @@ class WebInterface(object):
'$first': firstchar.lower(), '$first': firstchar.lower(),
} }
folder = pattern_substitute(folder_format.strip(), values, normalize=True) folder = helpers.pattern_substitute(folder_format.strip(), values, normalize=True)
folder = replace_illegal_chars(folder, type="folder") folder = helpers.replace_illegal_chars(folder, type="folder")
folder = folder.replace('./', '_/').replace('/.', '/_') folder = folder.replace('./', '_/').replace('/.', '/_')
if folder.endswith('.'): if folder.endswith('.'):
@@ -427,9 +419,9 @@ class WebInterface(object):
myDB = db.DBConnection() myDB = db.DBConnection()
for artist in args: for artist in args:
myDB.action('DELETE FROM newartists WHERE ArtistName=?', myDB.action('DELETE FROM newartists WHERE ArtistName=?',
[artist]) [artist.decode(headphones.SYS_ENCODING, 'replace')])
myDB.action('UPDATE have SET Matched="Ignored" WHERE ArtistName=?', myDB.action('UPDATE have SET Matched="Ignored" WHERE ArtistName=?',
[artist]) [artist.decode(headphones.SYS_ENCODING, 'replace')])
logger.info("Artist %s removed from new artist list and set to ignored" % artist) logger.info("Artist %s removed from new artist list and set to ignored" % artist)
raise cherrypy.HTTPRedirect("home") raise cherrypy.HTTPRedirect("home")
@@ -452,27 +444,40 @@ class WebInterface(object):
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
def choose_specific_download(self, AlbumID): def choose_specific_download(self, AlbumID):
results = searcher.searchforalbum(AlbumID, choose_specific_download=True) or [] results = searcher.searchforalbum(AlbumID, choose_specific_download=True)
return list(map(asdict, results))
data = []
for result in results:
result_dict = {
'title': result[0],
'size': result[1],
'url': result[2],
'provider': result[3],
'kind': result[4],
'matches': result[5]
}
data.append(result_dict)
return data
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
def download_specific_release(self, AlbumID, title, size, url, provider, kind, **kwargs): def download_specific_release(self, AlbumID, title, size, url, provider, kind, **kwargs):
# Handle situations where the torrent url contains arguments that are parsed # Handle situations where the torrent url contains arguments that are parsed
if kwargs: if kwargs:
url = parse.quote(url, safe=":?/=&") + '&' + parse.urlencode(kwargs) url = urllib.parse.quote(url, safe=":?/=&") + '&' + urllib.parse.urlencode(kwargs)
try: try:
result = [Result(title, int(size), url, provider, kind, True)] result = [(title, int(size), url, provider, kind)]
except ValueError: except ValueError:
result = [Result(title, float(size), url, provider, kind, True)] result = [(title, float(size), url, provider, kind)]
logger.info("Making sure we can download the chosen result") logger.info("Making sure we can download the chosen result")
data, result = searcher.preprocess(result) (data, bestqual) = searcher.preprocess(result)
if data and result: if data and bestqual:
myDB = db.DBConnection() myDB = db.DBConnection()
album = myDB.action('SELECT * from albums WHERE AlbumID=?', [AlbumID]).fetchone() album = myDB.action('SELECT * from albums WHERE AlbumID=?', [AlbumID]).fetchone()
searcher.send_to_downloader(data, result, album) searcher.send_to_downloader(data, bestqual, album)
return {'result': 'success'} return {'result': 'success'}
else: else:
return {'result': 'failure'} return {'result': 'failure'}
@@ -585,7 +590,7 @@ class WebInterface(object):
for albums in have_albums: for albums in have_albums:
# Have to skip over manually matched tracks # Have to skip over manually matched tracks
if albums['ArtistName'] and albums['AlbumTitle'] and albums['TrackTitle']: if albums['ArtistName'] and albums['AlbumTitle'] and albums['TrackTitle']:
original_clean = clean_name( original_clean = helpers.clean_name(
albums['ArtistName'] + " " + albums['AlbumTitle'] + " " + albums['TrackTitle']) albums['ArtistName'] + " " + albums['AlbumTitle'] + " " + albums['TrackTitle'])
# else: # else:
# original_clean = None # original_clean = None
@@ -632,8 +637,8 @@ class WebInterface(object):
(artist, album)) (artist, album))
elif action == "matchArtist": elif action == "matchArtist":
existing_artist_clean = clean_name(existing_artist).lower() existing_artist_clean = helpers.clean_name(existing_artist).lower()
new_artist_clean = clean_name(new_artist).lower() new_artist_clean = helpers.clean_name(new_artist).lower()
if new_artist_clean != existing_artist_clean: if new_artist_clean != existing_artist_clean:
have_tracks = myDB.action( have_tracks = myDB.action(
'SELECT Matched, CleanName, Location, BitRate, Format FROM have WHERE ArtistName=?', 'SELECT Matched, CleanName, Location, BitRate, Format FROM have WHERE ArtistName=?',
@@ -677,10 +682,10 @@ class WebInterface(object):
"Artist %s already named appropriately; nothing to modify" % existing_artist) "Artist %s already named appropriately; nothing to modify" % existing_artist)
elif action == "matchAlbum": elif action == "matchAlbum":
existing_artist_clean = clean_name(existing_artist).lower() existing_artist_clean = helpers.clean_name(existing_artist).lower()
new_artist_clean = clean_name(new_artist).lower() new_artist_clean = helpers.clean_name(new_artist).lower()
existing_album_clean = clean_name(existing_album).lower() existing_album_clean = helpers.clean_name(existing_album).lower()
new_album_clean = clean_name(new_album).lower() new_album_clean = helpers.clean_name(new_album).lower()
existing_clean_string = existing_artist_clean + " " + existing_album_clean existing_clean_string = existing_artist_clean + " " + existing_album_clean
new_clean_string = new_artist_clean + " " + new_album_clean new_clean_string = new_artist_clean + " " + new_album_clean
if existing_clean_string != new_clean_string: if existing_clean_string != new_clean_string:
@@ -736,7 +741,7 @@ class WebInterface(object):
'SELECT ArtistName, AlbumTitle, TrackTitle, CleanName, Matched from have') 'SELECT ArtistName, AlbumTitle, TrackTitle, CleanName, Matched from have')
for albums in manualalbums: for albums in manualalbums:
if albums['ArtistName'] and albums['AlbumTitle'] and albums['TrackTitle']: if albums['ArtistName'] and albums['AlbumTitle'] and albums['TrackTitle']:
original_clean = clean_name( original_clean = helpers.clean_name(
albums['ArtistName'] + " " + albums['AlbumTitle'] + " " + albums['TrackTitle']) albums['ArtistName'] + " " + albums['AlbumTitle'] + " " + albums['TrackTitle'])
if albums['Matched'] == "Ignored" or albums['Matched'] == "Manual" or albums[ if albums['Matched'] == "Ignored" or albums['Matched'] == "Manual" or albums[
'CleanName'] != original_clean: 'CleanName'] != original_clean:
@@ -777,14 +782,14 @@ class WebInterface(object):
[artist]) [artist])
update_count = 0 update_count = 0
for tracks in update_clean: for tracks in update_clean:
original_clean = clean_name( original_clean = helpers.clean_name(
tracks['ArtistName'] + " " + tracks['AlbumTitle'] + " " + tracks[ tracks['ArtistName'] + " " + tracks['AlbumTitle'] + " " + tracks[
'TrackTitle']).lower() 'TrackTitle']).lower()
album = tracks['AlbumTitle'] album = tracks['AlbumTitle']
track_title = tracks['TrackTitle'] track_title = tracks['TrackTitle']
if tracks['CleanName'] != original_clean: if tracks['CleanName'] != original_clean:
artist_id_check = myDB.action('SELECT ArtistID FROM tracks WHERE CleanName = ?', artist_id_check = myDB.action('SELECT ArtistID FROM tracks WHERE CleanName = ?',
[tracks['CleanName']]).fetchone() [tracks['CleanName']]).fetchone()
if artist_id_check: if artist_id_check:
artist_id = artist_id_check[0] artist_id = artist_id_check[0]
myDB.action( myDB.action(
@@ -809,7 +814,7 @@ class WebInterface(object):
(artist, album)) (artist, album))
update_count = 0 update_count = 0
for tracks in update_clean: for tracks in update_clean:
original_clean = clean_name( original_clean = helpers.clean_name(
tracks['ArtistName'] + " " + tracks['AlbumTitle'] + " " + tracks[ tracks['ArtistName'] + " " + tracks['AlbumTitle'] + " " + tracks[
'TrackTitle']).lower() 'TrackTitle']).lower()
track_title = tracks['TrackTitle'] track_title = tracks['TrackTitle']
@@ -1017,7 +1022,9 @@ class WebInterface(object):
totalcount = myDB.select('SELECT COUNT(*) from artists')[0][0] totalcount = myDB.select('SELECT COUNT(*) from artists')[0][0]
if sortbyhavepercent: if sortbyhavepercent:
filtered.sort(key=have_pct_have_total, reverse=sSortDir_0 == "asc") filtered.sort(key=lambda x: (
float(x['HaveTracks']) / x['TotalTracks'] if x['TotalTracks'] > 0 else 0.0,
x['HaveTracks'] if x['HaveTracks'] else 0.0), reverse=sSortDir_0 == "asc")
# can't figure out how to change the datatables default sorting order when its using an ajax datasource so ill # can't figure out how to change the datatables default sorting order when its using an ajax datasource so ill
# just reverse it here and the first click on the "Latest Album" header will sort by descending release date # just reverse it here and the first click on the "Latest Album" header will sort by descending release date
@@ -1071,7 +1078,7 @@ class WebInterface(object):
data[counter] = album['AlbumTitle'] data[counter] = album['AlbumTitle']
counter += 1 counter += 1
return data return data
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
@@ -1183,7 +1190,6 @@ class WebInterface(object):
"deluge_password": headphones.CONFIG.DELUGE_PASSWORD, "deluge_password": headphones.CONFIG.DELUGE_PASSWORD,
"deluge_label": headphones.CONFIG.DELUGE_LABEL, "deluge_label": headphones.CONFIG.DELUGE_LABEL,
"deluge_done_directory": headphones.CONFIG.DELUGE_DONE_DIRECTORY, "deluge_done_directory": headphones.CONFIG.DELUGE_DONE_DIRECTORY,
"deluge_download_directory": headphones.CONFIG.DELUGE_DOWNLOAD_DIRECTORY,
"deluge_paused": checked(headphones.CONFIG.DELUGE_PAUSED), "deluge_paused": checked(headphones.CONFIG.DELUGE_PAUSED),
"utorrent_host": headphones.CONFIG.UTORRENT_HOST, "utorrent_host": headphones.CONFIG.UTORRENT_HOST,
"utorrent_username": headphones.CONFIG.UTORRENT_USERNAME, "utorrent_username": headphones.CONFIG.UTORRENT_USERNAME,
@@ -1198,8 +1204,6 @@ class WebInterface(object):
"torrent_downloader_deluge": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 3), "torrent_downloader_deluge": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 3),
"torrent_downloader_qbittorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 4), "torrent_downloader_qbittorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 4),
"download_dir": headphones.CONFIG.DOWNLOAD_DIR, "download_dir": headphones.CONFIG.DOWNLOAD_DIR,
"soulseek_download_dir": headphones.CONFIG.SOULSEEK_DOWNLOAD_DIR,
"soulseek_incomplete_download_dir": headphones.CONFIG.SOULSEEK_INCOMPLETE_DOWNLOAD_DIR,
"use_blackhole": checked(headphones.CONFIG.BLACKHOLE), "use_blackhole": checked(headphones.CONFIG.BLACKHOLE),
"blackhole_dir": headphones.CONFIG.BLACKHOLE_DIR, "blackhole_dir": headphones.CONFIG.BLACKHOLE_DIR,
"usenet_retention": headphones.CONFIG.USENET_RETENTION, "usenet_retention": headphones.CONFIG.USENET_RETENTION,
@@ -1231,6 +1235,13 @@ class WebInterface(object):
"use_piratebay": checked(headphones.CONFIG.PIRATEBAY), "use_piratebay": checked(headphones.CONFIG.PIRATEBAY),
"piratebay_proxy_url": headphones.CONFIG.PIRATEBAY_PROXY_URL, "piratebay_proxy_url": headphones.CONFIG.PIRATEBAY_PROXY_URL,
"piratebay_ratio": headphones.CONFIG.PIRATEBAY_RATIO, "piratebay_ratio": headphones.CONFIG.PIRATEBAY_RATIO,
"use_oldpiratebay": checked(headphones.CONFIG.OLDPIRATEBAY),
"oldpiratebay_url": headphones.CONFIG.OLDPIRATEBAY_URL,
"oldpiratebay_ratio": headphones.CONFIG.OLDPIRATEBAY_RATIO,
"use_waffles": checked(headphones.CONFIG.WAFFLES),
"waffles_uid": headphones.CONFIG.WAFFLES_UID,
"waffles_passkey": headphones.CONFIG.WAFFLES_PASSKEY,
"waffles_ratio": headphones.CONFIG.WAFFLES_RATIO,
"use_rutracker": checked(headphones.CONFIG.RUTRACKER), "use_rutracker": checked(headphones.CONFIG.RUTRACKER),
"rutracker_user": headphones.CONFIG.RUTRACKER_USER, "rutracker_user": headphones.CONFIG.RUTRACKER_USER,
"rutracker_password": headphones.CONFIG.RUTRACKER_PASSWORD, "rutracker_password": headphones.CONFIG.RUTRACKER_PASSWORD,
@@ -1242,7 +1253,6 @@ class WebInterface(object):
"orpheus_ratio": headphones.CONFIG.ORPHEUS_RATIO, "orpheus_ratio": headphones.CONFIG.ORPHEUS_RATIO,
"orpheus_url": headphones.CONFIG.ORPHEUS_URL, "orpheus_url": headphones.CONFIG.ORPHEUS_URL,
"use_redacted": checked(headphones.CONFIG.REDACTED), "use_redacted": checked(headphones.CONFIG.REDACTED),
"redacted_apikey": headphones.CONFIG.REDACTED_APIKEY,
"redacted_username": headphones.CONFIG.REDACTED_USERNAME, "redacted_username": headphones.CONFIG.REDACTED_USERNAME,
"redacted_password": headphones.CONFIG.REDACTED_PASSWORD, "redacted_password": headphones.CONFIG.REDACTED_PASSWORD,
"redacted_ratio": headphones.CONFIG.REDACTED_RATIO, "redacted_ratio": headphones.CONFIG.REDACTED_RATIO,
@@ -1265,7 +1275,6 @@ class WebInterface(object):
"cue_split_shntool_path": headphones.CONFIG.CUE_SPLIT_SHNTOOL_PATH, "cue_split_shntool_path": headphones.CONFIG.CUE_SPLIT_SHNTOOL_PATH,
"move_files": checked(headphones.CONFIG.MOVE_FILES), "move_files": checked(headphones.CONFIG.MOVE_FILES),
"rename_files": checked(headphones.CONFIG.RENAME_FILES), "rename_files": checked(headphones.CONFIG.RENAME_FILES),
"rename_single_disc_ignore": checked(headphones.CONFIG.RENAME_SINGLE_DISC_IGNORE),
"correct_metadata": checked(headphones.CONFIG.CORRECT_METADATA), "correct_metadata": checked(headphones.CONFIG.CORRECT_METADATA),
"cleanup_files": checked(headphones.CONFIG.CLEANUP_FILES), "cleanup_files": checked(headphones.CONFIG.CLEANUP_FILES),
"keep_nfo": checked(headphones.CONFIG.KEEP_NFO), "keep_nfo": checked(headphones.CONFIG.KEEP_NFO),
@@ -1293,7 +1302,6 @@ class WebInterface(object):
"prefer_torrents_0": radio(headphones.CONFIG.PREFER_TORRENTS, 0), "prefer_torrents_0": radio(headphones.CONFIG.PREFER_TORRENTS, 0),
"prefer_torrents_1": radio(headphones.CONFIG.PREFER_TORRENTS, 1), "prefer_torrents_1": radio(headphones.CONFIG.PREFER_TORRENTS, 1),
"prefer_torrents_2": radio(headphones.CONFIG.PREFER_TORRENTS, 2), "prefer_torrents_2": radio(headphones.CONFIG.PREFER_TORRENTS, 2),
"prefer_torrents_3": radio(headphones.CONFIG.PREFER_TORRENTS, 3),
"magnet_links_0": radio(headphones.CONFIG.MAGNET_LINKS, 0), "magnet_links_0": radio(headphones.CONFIG.MAGNET_LINKS, 0),
"magnet_links_1": radio(headphones.CONFIG.MAGNET_LINKS, 1), "magnet_links_1": radio(headphones.CONFIG.MAGNET_LINKS, 1),
"magnet_links_2": radio(headphones.CONFIG.MAGNET_LINKS, 2), "magnet_links_2": radio(headphones.CONFIG.MAGNET_LINKS, 2),
@@ -1383,7 +1391,6 @@ class WebInterface(object):
"custompass": headphones.CONFIG.CUSTOMPASS, "custompass": headphones.CONFIG.CUSTOMPASS,
"hpuser": headphones.CONFIG.HPUSER, "hpuser": headphones.CONFIG.HPUSER,
"hppass": headphones.CONFIG.HPPASS, "hppass": headphones.CONFIG.HPPASS,
"lastfm_apikey": headphones.CONFIG.LASTFM_APIKEY,
"songkick_enabled": checked(headphones.CONFIG.SONGKICK_ENABLED), "songkick_enabled": checked(headphones.CONFIG.SONGKICK_ENABLED),
"songkick_apikey": headphones.CONFIG.SONGKICK_APIKEY, "songkick_apikey": headphones.CONFIG.SONGKICK_APIKEY,
"songkick_location": headphones.CONFIG.SONGKICK_LOCATION, "songkick_location": headphones.CONFIG.SONGKICK_LOCATION,
@@ -1411,12 +1418,7 @@ class WebInterface(object):
"join_enabled": checked(headphones.CONFIG.JOIN_ENABLED), "join_enabled": checked(headphones.CONFIG.JOIN_ENABLED),
"join_onsnatch": checked(headphones.CONFIG.JOIN_ONSNATCH), "join_onsnatch": checked(headphones.CONFIG.JOIN_ONSNATCH),
"join_apikey": headphones.CONFIG.JOIN_APIKEY, "join_apikey": headphones.CONFIG.JOIN_APIKEY,
"join_deviceid": headphones.CONFIG.JOIN_DEVICEID, "join_deviceid": headphones.CONFIG.JOIN_DEVICEID
"use_bandcamp": checked(headphones.CONFIG.BANDCAMP),
"bandcamp_dir": headphones.CONFIG.BANDCAMP_DIR,
'soulseek_api_url': headphones.CONFIG.SOULSEEK_API_URL,
'soulseek_api_key': headphones.CONFIG.SOULSEEK_API_KEY,
'use_soulseek': checked(headphones.CONFIG.SOULSEEK)
} }
for k, v in config.items(): for k, v in config.items():
@@ -1461,11 +1463,12 @@ class WebInterface(object):
checked_configs = [ checked_configs = [
"launch_browser", "enable_https", "api_enabled", "use_blackhole", "headphones_indexer", "launch_browser", "enable_https", "api_enabled", "use_blackhole", "headphones_indexer",
"use_newznab", "newznab_enabled", "use_torznab", "torznab_enabled", "use_newznab", "newznab_enabled", "use_torznab", "torznab_enabled",
"use_nzbsorg", "use_omgwtfnzbs", "use_piratebay", "use_rutracker", "use_nzbsorg", "use_omgwtfnzbs", "use_piratebay", "use_oldpiratebay",
"use_waffles", "use_rutracker",
"use_orpheus", "use_redacted", "redacted_use_fltoken", "preferred_bitrate_allow_lossless", "use_orpheus", "use_redacted", "redacted_use_fltoken", "preferred_bitrate_allow_lossless",
"detect_bitrate", "ignore_clean_releases", "freeze_db", "cue_split", "move_files", "detect_bitrate", "ignore_clean_releases", "freeze_db", "cue_split", "move_files",
"rename_files", "rename_single_disc_ignore", "correct_metadata", "cleanup_files", "rename_files", "correct_metadata", "cleanup_files", "keep_nfo", "add_album_art",
"keep_nfo", "add_album_art", "embed_album_art", "embed_lyrics", "embed_album_art", "embed_lyrics",
"replace_existing_folders", "keep_original_folder", "file_underscores", "replace_existing_folders", "keep_original_folder", "file_underscores",
"include_extras", "official_releases_only", "include_extras", "official_releases_only",
"wait_until_release_date", "autowant_upcoming", "autowant_all", "wait_until_release_date", "autowant_upcoming", "autowant_all",
@@ -1484,7 +1487,7 @@ class WebInterface(object):
"songkick_enabled", "songkick_filter_enabled", "songkick_enabled", "songkick_filter_enabled",
"mpc_enabled", "email_enabled", "email_ssl", "email_tls", "email_onsnatch", "mpc_enabled", "email_enabled", "email_ssl", "email_tls", "email_onsnatch",
"customauth", "idtag", "deluge_paused", "customauth", "idtag", "deluge_paused",
"join_enabled", "join_onsnatch", "use_bandcamp", "use_soulseek" "join_enabled", "join_onsnatch"
] ]
for checked_config in checked_configs: for checked_config in checked_configs:
if checked_config not in kwargs: if checked_config not in kwargs:
+4 -9
View File
@@ -1,10 +1,5 @@
from pkg_resources import get_distribution, DistributionNotFound version_info = (3, 0, 1)
version = '3.0.1'
release = '3.0.1'
try: __version__ = release # PEP 396
release = get_distribution('APScheduler').version.split('-')[0]
except DistributionNotFound:
release = '3.5.0'
version_info = tuple(int(x) if x.isdigit() else x for x in release.split('.'))
version = __version__ = '.'.join(str(x) for x in version_info[:3])
del get_distribution, DistributionNotFound
+21 -42
View File
@@ -1,33 +1,25 @@
__all__ = ('EVENT_SCHEDULER_STARTED', 'EVENT_SCHEDULER_SHUTDOWN', 'EVENT_SCHEDULER_PAUSED', __all__ = ('EVENT_SCHEDULER_START', 'EVENT_SCHEDULER_SHUTDOWN', 'EVENT_EXECUTOR_ADDED', 'EVENT_EXECUTOR_REMOVED',
'EVENT_SCHEDULER_RESUMED', 'EVENT_EXECUTOR_ADDED', 'EVENT_EXECUTOR_REMOVED', 'EVENT_JOBSTORE_ADDED', 'EVENT_JOBSTORE_REMOVED', 'EVENT_ALL_JOBS_REMOVED', 'EVENT_JOB_ADDED',
'EVENT_JOBSTORE_ADDED', 'EVENT_JOBSTORE_REMOVED', 'EVENT_ALL_JOBS_REMOVED', 'EVENT_JOB_REMOVED', 'EVENT_JOB_MODIFIED', 'EVENT_JOB_EXECUTED', 'EVENT_JOB_ERROR', 'EVENT_JOB_MISSED',
'EVENT_JOB_ADDED', 'EVENT_JOB_REMOVED', 'EVENT_JOB_MODIFIED', 'EVENT_JOB_EXECUTED', 'SchedulerEvent', 'JobEvent', 'JobExecutionEvent')
'EVENT_JOB_ERROR', 'EVENT_JOB_MISSED', 'EVENT_JOB_SUBMITTED', 'EVENT_JOB_MAX_INSTANCES',
'SchedulerEvent', 'JobEvent', 'JobExecutionEvent', 'JobSubmissionEvent')
EVENT_SCHEDULER_STARTED = EVENT_SCHEDULER_START = 2 ** 0 EVENT_SCHEDULER_START = 1
EVENT_SCHEDULER_SHUTDOWN = 2 ** 1 EVENT_SCHEDULER_SHUTDOWN = 2
EVENT_SCHEDULER_PAUSED = 2 ** 2 EVENT_EXECUTOR_ADDED = 4
EVENT_SCHEDULER_RESUMED = 2 ** 3 EVENT_EXECUTOR_REMOVED = 8
EVENT_EXECUTOR_ADDED = 2 ** 4 EVENT_JOBSTORE_ADDED = 16
EVENT_EXECUTOR_REMOVED = 2 ** 5 EVENT_JOBSTORE_REMOVED = 32
EVENT_JOBSTORE_ADDED = 2 ** 6 EVENT_ALL_JOBS_REMOVED = 64
EVENT_JOBSTORE_REMOVED = 2 ** 7 EVENT_JOB_ADDED = 128
EVENT_ALL_JOBS_REMOVED = 2 ** 8 EVENT_JOB_REMOVED = 256
EVENT_JOB_ADDED = 2 ** 9 EVENT_JOB_MODIFIED = 512
EVENT_JOB_REMOVED = 2 ** 10 EVENT_JOB_EXECUTED = 1024
EVENT_JOB_MODIFIED = 2 ** 11 EVENT_JOB_ERROR = 2048
EVENT_JOB_EXECUTED = 2 ** 12 EVENT_JOB_MISSED = 4096
EVENT_JOB_ERROR = 2 ** 13 EVENT_ALL = (EVENT_SCHEDULER_START | EVENT_SCHEDULER_SHUTDOWN | EVENT_JOBSTORE_ADDED | EVENT_JOBSTORE_REMOVED |
EVENT_JOB_MISSED = 2 ** 14
EVENT_JOB_SUBMITTED = 2 ** 15
EVENT_JOB_MAX_INSTANCES = 2 ** 16
EVENT_ALL = (EVENT_SCHEDULER_STARTED | EVENT_SCHEDULER_SHUTDOWN | EVENT_SCHEDULER_PAUSED |
EVENT_SCHEDULER_RESUMED | EVENT_EXECUTOR_ADDED | EVENT_EXECUTOR_REMOVED |
EVENT_JOBSTORE_ADDED | EVENT_JOBSTORE_REMOVED | EVENT_ALL_JOBS_REMOVED |
EVENT_JOB_ADDED | EVENT_JOB_REMOVED | EVENT_JOB_MODIFIED | EVENT_JOB_EXECUTED | EVENT_JOB_ADDED | EVENT_JOB_REMOVED | EVENT_JOB_MODIFIED | EVENT_JOB_EXECUTED |
EVENT_JOB_ERROR | EVENT_JOB_MISSED | EVENT_JOB_SUBMITTED | EVENT_JOB_MAX_INSTANCES) EVENT_JOB_ERROR | EVENT_JOB_MISSED)
class SchedulerEvent(object): class SchedulerEvent(object):
@@ -63,21 +55,9 @@ class JobEvent(SchedulerEvent):
self.jobstore = jobstore self.jobstore = jobstore
class JobSubmissionEvent(JobEvent):
"""
An event that concerns the submission of a job to its executor.
:ivar scheduled_run_times: a list of datetimes when the job was intended to run
"""
def __init__(self, code, job_id, jobstore, scheduled_run_times):
super(JobSubmissionEvent, self).__init__(code, job_id, jobstore)
self.scheduled_run_times = scheduled_run_times
class JobExecutionEvent(JobEvent): class JobExecutionEvent(JobEvent):
""" """
An event that concerns the running of a job within its executor. An event that concerns the execution of individual jobs.
:ivar scheduled_run_time: the time when the job was scheduled to be run :ivar scheduled_run_time: the time when the job was scheduled to be run
:ivar retval: the return value of the successfully executed job :ivar retval: the return value of the successfully executed job
@@ -85,8 +65,7 @@ class JobExecutionEvent(JobEvent):
:ivar traceback: a formatted traceback for the exception :ivar traceback: a formatted traceback for the exception
""" """
def __init__(self, code, job_id, jobstore, scheduled_run_time, retval=None, exception=None, def __init__(self, code, job_id, jobstore, scheduled_run_time, retval=None, exception=None, traceback=None):
traceback=None):
super(JobExecutionEvent, self).__init__(code, job_id, jobstore) super(JobExecutionEvent, self).__init__(code, job_id, jobstore)
self.scheduled_run_time = scheduled_run_time self.scheduled_run_time = scheduled_run_time
self.retval = retval self.retval = retval
+2 -26
View File
@@ -1,52 +1,28 @@
from __future__ import absolute_import
import sys import sys
from apscheduler.executors.base import BaseExecutor, run_job from apscheduler.executors.base import BaseExecutor, run_job
from apscheduler.executors.base_py3 import run_coroutine_job
from apscheduler.util import iscoroutinefunction_partial
class AsyncIOExecutor(BaseExecutor): class AsyncIOExecutor(BaseExecutor):
""" """
Runs jobs in the default executor of the event loop. Runs jobs in the default executor of the event loop.
If the job function is a native coroutine function, it is scheduled to be run directly in the
event loop as soon as possible. All other functions are run in the event loop's default
executor which is usually a thread pool.
Plugin alias: ``asyncio`` Plugin alias: ``asyncio``
""" """
def start(self, scheduler, alias): def start(self, scheduler, alias):
super(AsyncIOExecutor, self).start(scheduler, alias) super(AsyncIOExecutor, self).start(scheduler, alias)
self._eventloop = scheduler._eventloop self._eventloop = scheduler._eventloop
self._pending_futures = set()
def shutdown(self, wait=True):
# There is no way to honor wait=True without converting this method into a coroutine method
for f in self._pending_futures:
if not f.done():
f.cancel()
self._pending_futures.clear()
def _do_submit_job(self, job, run_times): def _do_submit_job(self, job, run_times):
def callback(f): def callback(f):
self._pending_futures.discard(f)
try: try:
events = f.result() events = f.result()
except BaseException: except:
self._run_job_error(job.id, *sys.exc_info()[1:]) self._run_job_error(job.id, *sys.exc_info()[1:])
else: else:
self._run_job_success(job.id, events) self._run_job_success(job.id, events)
if iscoroutinefunction_partial(job.func): f = self._eventloop.run_in_executor(None, run_job, job, job._jobstore_alias, run_times, self._logger.name)
coro = run_coroutine_job(job, job._jobstore_alias, run_times, self._logger.name)
f = self._eventloop.create_task(coro)
else:
f = self._eventloop.run_in_executor(None, run_job, job, job._jobstore_alias, run_times,
self._logger.name)
f.add_done_callback(callback) f.add_done_callback(callback)
self._pending_futures.add(f)
+20 -47
View File
@@ -8,15 +8,13 @@ import sys
from pytz import utc from pytz import utc
import six import six
from apscheduler.events import ( from apscheduler.events import JobExecutionEvent, EVENT_JOB_MISSED, EVENT_JOB_ERROR, EVENT_JOB_EXECUTED
JobExecutionEvent, EVENT_JOB_MISSED, EVENT_JOB_ERROR, EVENT_JOB_EXECUTED)
class MaxInstancesReachedError(Exception): class MaxInstancesReachedError(Exception):
def __init__(self, job): def __init__(self, job):
super(MaxInstancesReachedError, self).__init__( super(MaxInstancesReachedError, self).__init__(
'Job "%s" has already reached its maximum number of instances (%d)' % 'Job "%s" has already reached its maximum number of instances (%d)' % (job.id, job.max_instances))
(job.id, job.max_instances))
class BaseExecutor(six.with_metaclass(ABCMeta, object)): class BaseExecutor(six.with_metaclass(ABCMeta, object)):
@@ -32,14 +30,13 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
def start(self, scheduler, alias): def start(self, scheduler, alias):
""" """
Called by the scheduler when the scheduler is being started or when the executor is being Called by the scheduler when the scheduler is being started or when the executor is being added to an already
added to an already running scheduler. running scheduler.
:param apscheduler.schedulers.base.BaseScheduler scheduler: the scheduler that is starting :param apscheduler.schedulers.base.BaseScheduler scheduler: the scheduler that is starting this executor
this executor
:param str|unicode alias: alias of this executor as it was assigned to the scheduler :param str|unicode alias: alias of this executor as it was assigned to the scheduler
""" """
self._scheduler = scheduler self._scheduler = scheduler
self._lock = scheduler._create_lock() self._lock = scheduler._create_lock()
self._logger = logging.getLogger('apscheduler.executors.%s' % alias) self._logger = logging.getLogger('apscheduler.executors.%s' % alias)
@@ -48,8 +45,7 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
""" """
Shuts down this executor. Shuts down this executor.
:param bool wait: ``True`` to wait until all submitted jobs :param bool wait: ``True`` to wait until all submitted jobs have been executed
have been executed
""" """
def submit_job(self, job, run_times): def submit_job(self, job, run_times):
@@ -57,12 +53,10 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
Submits job for execution. Submits job for execution.
:param Job job: job to execute :param Job job: job to execute
:param list[datetime] run_times: list of datetimes specifying :param list[datetime] run_times: list of datetimes specifying when the job should have been run
when the job should have been run :raises MaxInstancesReachedError: if the maximum number of allowed instances for this job has been reached
:raises MaxInstancesReachedError: if the maximum number of
allowed instances for this job has been reached
""" """
assert self._lock is not None, 'This executor has not been started yet' assert self._lock is not None, 'This executor has not been started yet'
with self._lock: with self._lock:
if self._instances[job.id] >= job.max_instances: if self._instances[job.id] >= job.max_instances:
@@ -76,71 +70,50 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
"""Performs the actual task of scheduling `run_job` to be called.""" """Performs the actual task of scheduling `run_job` to be called."""
def _run_job_success(self, job_id, events): def _run_job_success(self, job_id, events):
""" """Called by the executor with the list of generated events when `run_job` has been successfully called."""
Called by the executor with the list of generated events when :func:`run_job` has been
successfully called.
"""
with self._lock: with self._lock:
self._instances[job_id] -= 1 self._instances[job_id] -= 1
if self._instances[job_id] == 0:
del self._instances[job_id]
for event in events: for event in events:
self._scheduler._dispatch_event(event) self._scheduler._dispatch_event(event)
def _run_job_error(self, job_id, exc, traceback=None): def _run_job_error(self, job_id, exc, traceback=None):
"""Called by the executor with the exception if there is an error calling `run_job`.""" """Called by the executor with the exception if there is an error calling `run_job`."""
with self._lock: with self._lock:
self._instances[job_id] -= 1 self._instances[job_id] -= 1
if self._instances[job_id] == 0:
del self._instances[job_id]
exc_info = (exc.__class__, exc, traceback) exc_info = (exc.__class__, exc, traceback)
self._logger.error('Error running job %s', job_id, exc_info=exc_info) self._logger.error('Error running job %s', job_id, exc_info=exc_info)
def run_job(job, jobstore_alias, run_times, logger_name): def run_job(job, jobstore_alias, run_times, logger_name):
""" """Called by executors to run the job. Returns a list of scheduler events to be dispatched by the scheduler."""
Called by executors to run the job. Returns a list of scheduler events to be dispatched by the
scheduler.
"""
events = [] events = []
logger = logging.getLogger(logger_name) logger = logging.getLogger(logger_name)
for run_time in run_times: for run_time in run_times:
# See if the job missed its run time window, and handle # See if the job missed its run time window, and handle possible misfires accordingly
# possible misfires accordingly
if job.misfire_grace_time is not None: if job.misfire_grace_time is not None:
difference = datetime.now(utc) - run_time difference = datetime.now(utc) - run_time
grace_time = timedelta(seconds=job.misfire_grace_time) grace_time = timedelta(seconds=job.misfire_grace_time)
if difference > grace_time: if difference > grace_time:
events.append(JobExecutionEvent(EVENT_JOB_MISSED, job.id, jobstore_alias, events.append(JobExecutionEvent(EVENT_JOB_MISSED, job.id, jobstore_alias, run_time))
run_time))
logger.warning('Run time of job "%s" was missed by %s', job, difference) logger.warning('Run time of job "%s" was missed by %s', job, difference)
continue continue
logger.info('Running job "%s" (scheduled at %s)', job, run_time) logger.info('Running job "%s" (scheduled at %s)', job, run_time)
try: try:
retval = job.func(*job.args, **job.kwargs) retval = job.func(*job.args, **job.kwargs)
except BaseException: except:
exc, tb = sys.exc_info()[1:] exc, tb = sys.exc_info()[1:]
formatted_tb = ''.join(format_tb(tb)) formatted_tb = ''.join(format_tb(tb))
events.append(JobExecutionEvent(EVENT_JOB_ERROR, job.id, jobstore_alias, run_time, events.append(JobExecutionEvent(EVENT_JOB_ERROR, job.id, jobstore_alias, run_time, exception=exc,
exception=exc, traceback=formatted_tb)) traceback=formatted_tb))
logger.exception('Job "%s" raised an exception', job) logger.exception('Job "%s" raised an exception', job)
# This is to prevent cyclic references that would lead to memory leaks
if six.PY2:
sys.exc_clear()
del tb
else:
import traceback
traceback.clear_frames(tb)
del tb
else: else:
events.append(JobExecutionEvent(EVENT_JOB_EXECUTED, job.id, jobstore_alias, run_time, events.append(JobExecutionEvent(EVENT_JOB_EXECUTED, job.id, jobstore_alias, run_time, retval=retval))
retval=retval))
logger.info('Job "%s" executed successfully', job) logger.info('Job "%s" executed successfully', job)
return events return events
-43
View File
@@ -1,43 +0,0 @@
import logging
import sys
import traceback
from datetime import datetime, timedelta
from traceback import format_tb
from pytz import utc
from apscheduler.events import (
JobExecutionEvent, EVENT_JOB_MISSED, EVENT_JOB_ERROR, EVENT_JOB_EXECUTED)
async def run_coroutine_job(job, jobstore_alias, run_times, logger_name):
"""Coroutine version of run_job()."""
events = []
logger = logging.getLogger(logger_name)
for run_time in run_times:
# See if the job missed its run time window, and handle possible misfires accordingly
if job.misfire_grace_time is not None:
difference = datetime.now(utc) - run_time
grace_time = timedelta(seconds=job.misfire_grace_time)
if difference > grace_time:
events.append(JobExecutionEvent(EVENT_JOB_MISSED, job.id, jobstore_alias,
run_time))
logger.warning('Run time of job "%s" was missed by %s', job, difference)
continue
logger.info('Running job "%s" (scheduled at %s)', job, run_time)
try:
retval = await job.func(*job.args, **job.kwargs)
except BaseException:
exc, tb = sys.exc_info()[1:]
formatted_tb = ''.join(format_tb(tb))
events.append(JobExecutionEvent(EVENT_JOB_ERROR, job.id, jobstore_alias, run_time,
exception=exc, traceback=formatted_tb))
logger.exception('Job "%s" raised an exception', job)
traceback.clear_frames(tb)
else:
events.append(JobExecutionEvent(EVENT_JOB_EXECUTED, job.id, jobstore_alias, run_time,
retval=retval))
logger.info('Job "%s" executed successfully', job)
return events
+2 -3
View File
@@ -5,8 +5,7 @@ from apscheduler.executors.base import BaseExecutor, run_job
class DebugExecutor(BaseExecutor): class DebugExecutor(BaseExecutor):
""" """
A special executor that executes the target callable directly instead of deferring it to a A special executor that executes the target callable directly instead of deferring it to a thread or process.
thread or process.
Plugin alias: ``debug`` Plugin alias: ``debug``
""" """
@@ -14,7 +13,7 @@ class DebugExecutor(BaseExecutor):
def _do_submit_job(self, job, run_times): def _do_submit_job(self, job, run_times):
try: try:
events = run_job(job, job._jobstore_alias, run_times, self._logger.name) events = run_job(job, job._jobstore_alias, run_times, self._logger.name)
except BaseException: except:
self._run_job_error(job.id, *sys.exc_info()[1:]) self._run_job_error(job.id, *sys.exc_info()[1:])
else: else:
self._run_job_success(job.id, events) self._run_job_success(job.id, events)
+3 -4
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
import sys import sys
from apscheduler.executors.base import BaseExecutor, run_job from apscheduler.executors.base import BaseExecutor, run_job
@@ -21,10 +21,9 @@ class GeventExecutor(BaseExecutor):
def callback(greenlet): def callback(greenlet):
try: try:
events = greenlet.get() events = greenlet.get()
except BaseException: except:
self._run_job_error(job.id, *sys.exc_info()[1:]) self._run_job_error(job.id, *sys.exc_info()[1:])
else: else:
self._run_job_success(job.id, events) self._run_job_success(job.id, events)
gevent.spawn(run_job, job, job._jobstore_alias, run_times, self._logger.name).\ gevent.spawn(run_job, job, job._jobstore_alias, run_times, self._logger.name).link(callback)
link(callback)
+5 -22
View File
@@ -3,11 +3,6 @@ import concurrent.futures
from apscheduler.executors.base import BaseExecutor, run_job from apscheduler.executors.base import BaseExecutor, run_job
try:
from concurrent.futures.process import BrokenProcessPool
except ImportError:
BrokenProcessPool = None
class BasePoolExecutor(BaseExecutor): class BasePoolExecutor(BaseExecutor):
@abstractmethod @abstractmethod
@@ -24,13 +19,7 @@ class BasePoolExecutor(BaseExecutor):
else: else:
self._run_job_success(job.id, f.result()) self._run_job_success(job.id, f.result())
try: f = self._pool.submit(run_job, job, job._jobstore_alias, run_times, self._logger.name)
f = self._pool.submit(run_job, job, job._jobstore_alias, run_times, self._logger.name)
except BrokenProcessPool:
self._logger.warning('Process pool is broken; replacing pool with a fresh instance')
self._pool = self._pool.__class__(self._pool._max_workers)
f = self._pool.submit(run_job, job, job._jobstore_alias, run_times, self._logger.name)
f.add_done_callback(callback) f.add_done_callback(callback)
def shutdown(self, wait=True): def shutdown(self, wait=True):
@@ -44,13 +33,10 @@ class ThreadPoolExecutor(BasePoolExecutor):
Plugin alias: ``threadpool`` Plugin alias: ``threadpool``
:param max_workers: the maximum number of spawned threads. :param max_workers: the maximum number of spawned threads.
:param pool_kwargs: dict of keyword arguments to pass to the underlying
ThreadPoolExecutor constructor
""" """
def __init__(self, max_workers=10, pool_kwargs=None): def __init__(self, max_workers=10):
pool_kwargs = pool_kwargs or {} pool = concurrent.futures.ThreadPoolExecutor(int(max_workers))
pool = concurrent.futures.ThreadPoolExecutor(int(max_workers), **pool_kwargs)
super(ThreadPoolExecutor, self).__init__(pool) super(ThreadPoolExecutor, self).__init__(pool)
@@ -61,11 +47,8 @@ class ProcessPoolExecutor(BasePoolExecutor):
Plugin alias: ``processpool`` Plugin alias: ``processpool``
:param max_workers: the maximum number of spawned processes. :param max_workers: the maximum number of spawned processes.
:param pool_kwargs: dict of keyword arguments to pass to the underlying
ProcessPoolExecutor constructor
""" """
def __init__(self, max_workers=10, pool_kwargs=None): def __init__(self, max_workers=10):
pool_kwargs = pool_kwargs or {} pool = concurrent.futures.ProcessPoolExecutor(int(max_workers))
pool = concurrent.futures.ProcessPoolExecutor(int(max_workers), **pool_kwargs)
super(ProcessPoolExecutor, self).__init__(pool) super(ProcessPoolExecutor, self).__init__(pool)
-54
View File
@@ -1,54 +0,0 @@
from __future__ import absolute_import
import sys
from concurrent.futures import ThreadPoolExecutor
from tornado.gen import convert_yielded
from apscheduler.executors.base import BaseExecutor, run_job
try:
from apscheduler.executors.base_py3 import run_coroutine_job
from apscheduler.util import iscoroutinefunction_partial
except ImportError:
def iscoroutinefunction_partial(func):
return False
class TornadoExecutor(BaseExecutor):
"""
Runs jobs either in a thread pool or directly on the I/O loop.
If the job function is a native coroutine function, it is scheduled to be run directly in the
I/O loop as soon as possible. All other functions are run in a thread pool.
Plugin alias: ``tornado``
:param int max_workers: maximum number of worker threads in the thread pool
"""
def __init__(self, max_workers=10):
super(TornadoExecutor, self).__init__()
self.executor = ThreadPoolExecutor(max_workers)
def start(self, scheduler, alias):
super(TornadoExecutor, self).start(scheduler, alias)
self._ioloop = scheduler._ioloop
def _do_submit_job(self, job, run_times):
def callback(f):
try:
events = f.result()
except BaseException:
self._run_job_error(job.id, *sys.exc_info()[1:])
else:
self._run_job_success(job.id, events)
if iscoroutinefunction_partial(job.func):
f = run_coroutine_job(job, job._jobstore_alias, run_times, self._logger.name)
else:
f = self.executor.submit(run_job, job, job._jobstore_alias, run_times,
self._logger.name)
f = convert_yielded(f)
f.add_done_callback(callback)
+3 -3
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
from apscheduler.executors.base import BaseExecutor, run_job from apscheduler.executors.base import BaseExecutor, run_job
@@ -21,5 +21,5 @@ class TwistedExecutor(BaseExecutor):
else: else:
self._run_job_error(job.id, result.value, result.tb) self._run_job_error(job.id, result.value, result.tb)
self._reactor.getThreadPool().callInThreadWithCallback( self._reactor.getThreadPool().callInThreadWithCallback(callback, run_job, job, job._jobstore_alias, run_times,
callback, run_job, job, job._jobstore_alias, run_times, self._logger.name) self._logger.name)
+27 -77
View File
@@ -1,17 +1,11 @@
from inspect import ismethod, isclass from collections.abc import Iterable, Mapping
from uuid import uuid4 from uuid import uuid4
import six import six
from apscheduler.triggers.base import BaseTrigger from apscheduler.triggers.base import BaseTrigger
from apscheduler.util import ( from apscheduler.util import ref_to_obj, obj_to_ref, datetime_repr, repr_escape, get_callable_name, check_callable_args, \
ref_to_obj, obj_to_ref, datetime_repr, repr_escape, get_callable_name, check_callable_args, convert_to_datetime
convert_to_datetime)
try:
from collections.abc import Iterable, Mapping
except ImportError:
from collections import Iterable, Mapping
class Job(object): class Job(object):
@@ -27,20 +21,13 @@ class Job(object):
:var bool coalesce: whether to only run the job once when several run times are due :var bool coalesce: whether to only run the job once when several run times are due
:var trigger: the trigger object that controls the schedule of this job :var trigger: the trigger object that controls the schedule of this job
:var str executor: the name of the executor that will run this job :var str executor: the name of the executor that will run this job
:var int misfire_grace_time: the time (in seconds) how much this job's execution is allowed to :var int misfire_grace_time: the time (in seconds) how much this job's execution is allowed to be late
be late (``None`` means "allow the job to run no matter how late it is") :var int max_instances: the maximum number of concurrently executing instances allowed for this job
:var int max_instances: the maximum number of concurrently executing instances allowed for this
job
:var datetime.datetime next_run_time: the next scheduled run time of this job :var datetime.datetime next_run_time: the next scheduled run time of this job
.. note::
The ``misfire_grace_time`` has some non-obvious effects on job execution. See the
:ref:`missed-job-executions` section in the documentation for an in-depth explanation.
""" """
__slots__ = ('_scheduler', '_jobstore_alias', 'id', 'trigger', 'executor', 'func', 'func_ref', __slots__ = ('_scheduler', '_jobstore_alias', 'id', 'trigger', 'executor', 'func', 'func_ref', 'args', 'kwargs',
'args', 'kwargs', 'name', 'misfire_grace_time', 'coalesce', 'max_instances', 'name', 'misfire_grace_time', 'coalesce', 'max_instances', 'next_run_time')
'next_run_time', '__weakref__')
def __init__(self, scheduler, id=None, **kwargs): def __init__(self, scheduler, id=None, **kwargs):
super(Job, self).__init__() super(Job, self).__init__()
@@ -51,69 +38,53 @@ class Job(object):
def modify(self, **changes): def modify(self, **changes):
""" """
Makes the given changes to this job and saves it in the associated job store. Makes the given changes to this job and saves it in the associated job store.
Accepted keyword arguments are the same as the variables on this class. Accepted keyword arguments are the same as the variables on this class.
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.modify_job` .. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.modify_job`
:return Job: this job instance
""" """
self._scheduler.modify_job(self.id, self._jobstore_alias, **changes) self._scheduler.modify_job(self.id, self._jobstore_alias, **changes)
return self
def reschedule(self, trigger, **trigger_args): def reschedule(self, trigger, **trigger_args):
""" """
Shortcut for switching the trigger on this job. Shortcut for switching the trigger on this job.
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.reschedule_job` .. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.reschedule_job`
:return Job: this job instance
""" """
self._scheduler.reschedule_job(self.id, self._jobstore_alias, trigger, **trigger_args) self._scheduler.reschedule_job(self.id, self._jobstore_alias, trigger, **trigger_args)
return self
def pause(self): def pause(self):
""" """
Temporarily suspend the execution of this job. Temporarily suspend the execution of this job.
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.pause_job` .. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.pause_job`
:return Job: this job instance
""" """
self._scheduler.pause_job(self.id, self._jobstore_alias) self._scheduler.pause_job(self.id, self._jobstore_alias)
return self
def resume(self): def resume(self):
""" """
Resume the schedule of this job if previously paused. Resume the schedule of this job if previously paused.
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.resume_job` .. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.resume_job`
:return Job: this job instance
""" """
self._scheduler.resume_job(self.id, self._jobstore_alias) self._scheduler.resume_job(self.id, self._jobstore_alias)
return self
def remove(self): def remove(self):
""" """
Unschedules this job and removes it from its associated job store. Unschedules this job and removes it from its associated job store.
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.remove_job` .. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.remove_job`
""" """
self._scheduler.remove_job(self.id, self._jobstore_alias) self._scheduler.remove_job(self.id, self._jobstore_alias)
@property @property
def pending(self): def pending(self):
""" """Returns ``True`` if the referenced job is still waiting to be added to its designated job store."""
Returns ``True`` if the referenced job is still waiting to be added to its designated job
store.
"""
return self._jobstore_alias is None return self._jobstore_alias is None
# #
@@ -126,8 +97,8 @@ class Job(object):
:type now: datetime.datetime :type now: datetime.datetime
:rtype: list[datetime.datetime] :rtype: list[datetime.datetime]
""" """
run_times = [] run_times = []
next_run_time = self.next_run_time next_run_time = self.next_run_time
while next_run_time and next_run_time <= now: while next_run_time and next_run_time <= now:
@@ -137,11 +108,8 @@ class Job(object):
return run_times return run_times
def _modify(self, **changes): def _modify(self, **changes):
""" """Validates the changes to the Job and makes the modifications if and only if all of them validate."""
Validates the changes to the Job and makes the modifications if and only if all of them
validate.
"""
approved = {} approved = {}
if 'id' in changes: if 'id' in changes:
@@ -157,7 +125,7 @@ class Job(object):
args = changes.pop('args') if 'args' in changes else self.args args = changes.pop('args') if 'args' in changes else self.args
kwargs = changes.pop('kwargs') if 'kwargs' in changes else self.kwargs kwargs = changes.pop('kwargs') if 'kwargs' in changes else self.kwargs
if isinstance(func, six.string_types): if isinstance(func, str):
func_ref = func func_ref = func
func = ref_to_obj(func) func = ref_to_obj(func)
elif callable(func): elif callable(func):
@@ -209,8 +177,7 @@ class Job(object):
if 'trigger' in changes: if 'trigger' in changes:
trigger = changes.pop('trigger') trigger = changes.pop('trigger')
if not isinstance(trigger, BaseTrigger): if not isinstance(trigger, BaseTrigger):
raise TypeError('Expected a trigger instance, got %s instead' % raise TypeError('Expected a trigger instance, got %s instead' % trigger.__class__.__name__)
trigger.__class__.__name__)
approved['trigger'] = trigger approved['trigger'] = trigger
@@ -222,12 +189,10 @@ class Job(object):
if 'next_run_time' in changes: if 'next_run_time' in changes:
value = changes.pop('next_run_time') value = changes.pop('next_run_time')
approved['next_run_time'] = convert_to_datetime(value, self._scheduler.timezone, approved['next_run_time'] = convert_to_datetime(value, self._scheduler.timezone, 'next_run_time')
'next_run_time')
if changes: if changes:
raise AttributeError('The following are not modifiable attributes of Job: %s' % raise AttributeError('The following are not modifiable attributes of Job: %s' % ', '.join(changes))
', '.join(changes))
for key, value in six.iteritems(approved): for key, value in six.iteritems(approved):
setattr(self, key, value) setattr(self, key, value)
@@ -235,18 +200,9 @@ class Job(object):
def __getstate__(self): def __getstate__(self):
# Don't allow this Job to be serialized if the function reference could not be determined # Don't allow this Job to be serialized if the function reference could not be determined
if not self.func_ref: if not self.func_ref:
raise ValueError( raise ValueError('This Job cannot be serialized since the reference to its callable (%r) could not be '
'This Job cannot be serialized since the reference to its callable (%r) could not ' 'determined. Consider giving a textual reference (module:function name) instead.' %
'be determined. Consider giving a textual reference (module:function name) ' (self.func,))
'instead.' % (self.func,))
# Instance methods cannot survive serialization as-is, so store the "self" argument
# explicitly
func = self.func
if ismethod(func) and not isclass(func.__self__) and obj_to_ref(func) == self.func_ref:
args = (func.__self__,) + tuple(self.args)
else:
args = self.args
return { return {
'version': 1, 'version': 1,
@@ -254,7 +210,7 @@ class Job(object):
'func': self.func_ref, 'func': self.func_ref,
'trigger': self.trigger, 'trigger': self.trigger,
'executor': self.executor, 'executor': self.executor,
'args': args, 'args': self.args,
'kwargs': self.kwargs, 'kwargs': self.kwargs,
'name': self.name, 'name': self.name,
'misfire_grace_time': self.misfire_grace_time, 'misfire_grace_time': self.misfire_grace_time,
@@ -265,8 +221,7 @@ class Job(object):
def __setstate__(self, state): def __setstate__(self, state):
if state.get('version', 1) > 1: if state.get('version', 1) > 1:
raise ValueError('Job has version %s, but only version 1 can be handled' % raise ValueError('Job has version %s, but only version 1 can be handled' % state['version'])
state['version'])
self.id = state['id'] self.id = state['id']
self.func_ref = state['func'] self.func_ref = state['func']
@@ -290,13 +245,8 @@ class Job(object):
return '<Job (id=%s name=%s)>' % (repr_escape(self.id), repr_escape(self.name)) return '<Job (id=%s name=%s)>' % (repr_escape(self.id), repr_escape(self.name))
def __str__(self): def __str__(self):
return repr_escape(self.__unicode__()) return '%s (trigger: %s, next run at: %s)' % (repr_escape(self.name), repr_escape(str(self.trigger)),
datetime_repr(self.next_run_time))
def __unicode__(self): def __unicode__(self):
if hasattr(self, 'next_run_time'): return six.u('%s (trigger: %s, next run at: %s)') % (self.name, self.trigger, datetime_repr(self.next_run_time))
status = ('next run at: ' + datetime_repr(self.next_run_time) if
self.next_run_time else 'paused')
else:
status = 'pending'
return u'%s (trigger: %s, %s)' % (self.name, self.trigger, status)
+15 -31
View File
@@ -8,27 +8,23 @@ class JobLookupError(KeyError):
"""Raised when the job store cannot find a job for update or removal.""" """Raised when the job store cannot find a job for update or removal."""
def __init__(self, job_id): def __init__(self, job_id):
super(JobLookupError, self).__init__(u'No job by the id of %s was found' % job_id) super(JobLookupError, self).__init__(six.u('No job by the id of %s was found') % job_id)
class ConflictingIdError(KeyError): class ConflictingIdError(KeyError):
"""Raised when the uniqueness of job IDs is being violated.""" """Raised when the uniqueness of job IDs is being violated."""
def __init__(self, job_id): def __init__(self, job_id):
super(ConflictingIdError, self).__init__( super(ConflictingIdError, self).__init__(six.u('Job identifier (%s) conflicts with an existing job') % job_id)
u'Job identifier (%s) conflicts with an existing job' % job_id)
class TransientJobError(ValueError): class TransientJobError(ValueError):
""" """Raised when an attempt to add transient (with no func_ref) job to a persistent job store is detected."""
Raised when an attempt to add transient (with no func_ref) job to a persistent job store is
detected.
"""
def __init__(self, job_id): def __init__(self, job_id):
super(TransientJobError, self).__init__( super(TransientJobError, self).__init__(
u'Job (%s) cannot be added to this job store because a reference to the callable ' six.u('Job (%s) cannot be added to this job store because a reference to the callable could not be '
u'could not be determined.' % job_id) 'determined.') % job_id)
class BaseJobStore(six.with_metaclass(ABCMeta)): class BaseJobStore(six.with_metaclass(ABCMeta)):
@@ -40,11 +36,10 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
def start(self, scheduler, alias): def start(self, scheduler, alias):
""" """
Called by the scheduler when the scheduler is being started or when the job store is being Called by the scheduler when the scheduler is being started or when the job store is being added to an already
added to an already running scheduler. running scheduler.
:param apscheduler.schedulers.base.BaseScheduler scheduler: the scheduler that is starting :param apscheduler.schedulers.base.BaseScheduler scheduler: the scheduler that is starting this job store
this job store
:param str|unicode alias: alias of this job store as it was assigned to the scheduler :param str|unicode alias: alias of this job store as it was assigned to the scheduler
""" """
@@ -55,22 +50,13 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
def shutdown(self): def shutdown(self):
"""Frees any resources still bound to this job store.""" """Frees any resources still bound to this job store."""
def _fix_paused_jobs_sorting(self, jobs):
for i, job in enumerate(jobs):
if job.next_run_time is not None:
if i > 0:
paused_jobs = jobs[:i]
del jobs[:i]
jobs.extend(paused_jobs)
break
@abstractmethod @abstractmethod
def lookup_job(self, job_id): def lookup_job(self, job_id):
""" """
Returns a specific job, or ``None`` if it isn't found.. Returns a specific job, or ``None`` if it isn't found..
The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of the returned job to
the returned job to point to the scheduler and itself, respectively. point to the scheduler and itself, respectively.
:param str|unicode job_id: identifier of the job :param str|unicode job_id: identifier of the job
:rtype: Job :rtype: Job
@@ -89,8 +75,7 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
@abstractmethod @abstractmethod
def get_next_run_time(self): def get_next_run_time(self):
""" """
Returns the earliest run time of all the jobs stored in this job store, or ``None`` if Returns the earliest run time of all the jobs stored in this job store, or ``None`` if there are no active jobs.
there are no active jobs.
:rtype: datetime.datetime :rtype: datetime.datetime
""" """
@@ -98,12 +83,11 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
@abstractmethod @abstractmethod
def get_all_jobs(self): def get_all_jobs(self):
""" """
Returns a list of all jobs in this job store. Returns a list of all jobs in this job store. The returned jobs should be sorted by next run time (ascending).
The returned jobs should be sorted by next run time (ascending). Paused jobs (next_run_time is None) should be sorted last.
Paused jobs (next_run_time == None) should be sorted last.
The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of the returned jobs to
the returned jobs to point to the scheduler and itself, respectively. point to the scheduler and itself, respectively.
:rtype: list[Job] :rtype: list[Job]
""" """
+5 -6
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
from apscheduler.util import datetime_to_utc_timestamp from apscheduler.util import datetime_to_utc_timestamp
@@ -13,8 +13,7 @@ class MemoryJobStore(BaseJobStore):
def __init__(self): def __init__(self):
super(MemoryJobStore, self).__init__() super(MemoryJobStore, self).__init__()
# list of (job, timestamp), sorted by next_run_time and job id (ascending) self._jobs = [] # list of (job, timestamp), sorted by next_run_time and job id (ascending)
self._jobs = []
self._jobs_index = {} # id -> (job, timestamp) lookup table self._jobs_index = {} # id -> (job, timestamp) lookup table
def lookup_job(self, job_id): def lookup_job(self, job_id):
@@ -81,13 +80,13 @@ class MemoryJobStore(BaseJobStore):
def _get_job_index(self, timestamp, job_id): def _get_job_index(self, timestamp, job_id):
""" """
Returns the index of the given job, or if it's not found, the index where the job should be Returns the index of the given job, or if it's not found, the index where the job should be inserted based on
inserted based on the given timestamp. the given timestamp.
:type timestamp: int :type timestamp: int
:type job_id: str :type job_id: str
""" """
lo, hi = 0, len(self._jobs) lo, hi = 0, len(self._jobs)
timestamp = float('inf') if timestamp is None else timestamp timestamp = float('inf') if timestamp is None else timestamp
while lo < hi: while lo < hi:
+25 -42
View File
@@ -1,12 +1,11 @@
from __future__ import absolute_import
import warnings
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
from apscheduler.job import Job from apscheduler.job import Job
try: try:
import cPickle as pickle import pickle as pickle
except ImportError: # pragma: nocover except ImportError: # pragma: nocover
import pickle import pickle
@@ -20,18 +19,16 @@ except ImportError: # pragma: nocover
class MongoDBJobStore(BaseJobStore): class MongoDBJobStore(BaseJobStore):
""" """
Stores jobs in a MongoDB database. Any leftover keyword arguments are directly passed to Stores jobs in a MongoDB database. Any leftover keyword arguments are directly passed to pymongo's `MongoClient
pymongo's `MongoClient
<http://api.mongodb.org/python/current/api/pymongo/mongo_client.html#pymongo.mongo_client.MongoClient>`_. <http://api.mongodb.org/python/current/api/pymongo/mongo_client.html#pymongo.mongo_client.MongoClient>`_.
Plugin alias: ``mongodb`` Plugin alias: ``mongodb``
:param str database: database to store jobs in :param str database: database to store jobs in
:param str collection: collection to store jobs in :param str collection: collection to store jobs in
:param client: a :class:`~pymongo.mongo_client.MongoClient` instance to use instead of :param client: a :class:`~pymongo.mongo_client.MongoClient` instance to use instead of providing connection
providing connection arguments arguments
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the :param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the highest available
highest available
""" """
def __init__(self, database='apscheduler', collection='jobs', client=None, def __init__(self, database='apscheduler', collection='jobs', client=None,
@@ -45,22 +42,13 @@ class MongoDBJobStore(BaseJobStore):
raise ValueError('The "collection" parameter must not be empty') raise ValueError('The "collection" parameter must not be empty')
if client: if client:
self.client = maybe_ref(client) self.connection = maybe_ref(client)
else: else:
connect_args.setdefault('w', 1) connect_args.setdefault('w', 1)
self.client = MongoClient(**connect_args) self.connection = MongoClient(**connect_args)
self.collection = self.client[database][collection] self.collection = self.connection[database][collection]
self.collection.ensure_index('next_run_time', sparse=True)
def start(self, scheduler, alias):
super(MongoDBJobStore, self).start(scheduler, alias)
self.collection.create_index('next_run_time', sparse=True)
@property
def connection(self):
warnings.warn('The "connection" member is deprecated -- use "client" instead',
DeprecationWarning)
return self.client
def lookup_job(self, job_id): def lookup_job(self, job_id):
document = self.collection.find_one(job_id, ['job_state']) document = self.collection.find_one(job_id, ['job_state'])
@@ -71,19 +59,16 @@ class MongoDBJobStore(BaseJobStore):
return self._get_jobs({'next_run_time': {'$lte': timestamp}}) return self._get_jobs({'next_run_time': {'$lte': timestamp}})
def get_next_run_time(self): def get_next_run_time(self):
document = self.collection.find_one({'next_run_time': {'$ne': None}}, document = self.collection.find_one({'next_run_time': {'$ne': None}}, fields=['next_run_time'],
projection=['next_run_time'],
sort=[('next_run_time', ASCENDING)]) sort=[('next_run_time', ASCENDING)])
return utc_timestamp_to_datetime(document['next_run_time']) if document else None return utc_timestamp_to_datetime(document['next_run_time']) if document else None
def get_all_jobs(self): def get_all_jobs(self):
jobs = self._get_jobs({}) return self._get_jobs({})
self._fix_paused_jobs_sorting(jobs)
return jobs
def add_job(self, job): def add_job(self, job):
try: try:
self.collection.insert_one({ self.collection.insert({
'_id': job.id, '_id': job.id,
'next_run_time': datetime_to_utc_timestamp(job.next_run_time), 'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
'job_state': Binary(pickle.dumps(job.__getstate__(), self.pickle_protocol)) 'job_state': Binary(pickle.dumps(job.__getstate__(), self.pickle_protocol))
@@ -96,20 +81,20 @@ class MongoDBJobStore(BaseJobStore):
'next_run_time': datetime_to_utc_timestamp(job.next_run_time), 'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
'job_state': Binary(pickle.dumps(job.__getstate__(), self.pickle_protocol)) 'job_state': Binary(pickle.dumps(job.__getstate__(), self.pickle_protocol))
} }
result = self.collection.update_one({'_id': job.id}, {'$set': changes}) result = self.collection.update({'_id': job.id}, {'$set': changes})
if result and result.matched_count == 0: if result and result['n'] == 0:
raise JobLookupError(job.id) raise JobLookupError(id)
def remove_job(self, job_id): def remove_job(self, job_id):
result = self.collection.delete_one({'_id': job_id}) result = self.collection.remove(job_id)
if result and result.deleted_count == 0: if result and result['n'] == 0:
raise JobLookupError(job_id) raise JobLookupError(job_id)
def remove_all_jobs(self): def remove_all_jobs(self):
self.collection.delete_many({}) self.collection.remove()
def shutdown(self): def shutdown(self):
self.client.close() self.connection.disconnect()
def _reconstitute_job(self, job_state): def _reconstitute_job(self, job_state):
job_state = pickle.loads(job_state) job_state = pickle.loads(job_state)
@@ -122,20 +107,18 @@ class MongoDBJobStore(BaseJobStore):
def _get_jobs(self, conditions): def _get_jobs(self, conditions):
jobs = [] jobs = []
failed_job_ids = [] failed_job_ids = []
for document in self.collection.find(conditions, ['_id', 'job_state'], for document in self.collection.find(conditions, ['_id', 'job_state'], sort=[('next_run_time', ASCENDING)]):
sort=[('next_run_time', ASCENDING)]):
try: try:
jobs.append(self._reconstitute_job(document['job_state'])) jobs.append(self._reconstitute_job(document['job_state']))
except BaseException: except:
self._logger.exception('Unable to restore job "%s" -- removing it', self._logger.exception('Unable to restore job "%s" -- removing it', document['_id'])
document['_id'])
failed_job_ids.append(document['_id']) failed_job_ids.append(document['_id'])
# Remove all the jobs we failed to restore # Remove all the jobs we failed to restore
if failed_job_ids: if failed_job_ids:
self.collection.delete_many({'_id': {'$in': failed_job_ids}}) self.collection.remove({'_id': {'$in': failed_job_ids}})
return jobs return jobs
def __repr__(self): def __repr__(self):
return '<%s (client=%s)>' % (self.__class__.__name__, self.client) return '<%s (client=%s)>' % (self.__class__.__name__, self.connection)
+12 -24
View File
@@ -1,7 +1,5 @@
from __future__ import absolute_import
from datetime import datetime
from pytz import utc
import six import six
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
@@ -9,28 +7,26 @@ from apscheduler.util import datetime_to_utc_timestamp, utc_timestamp_to_datetim
from apscheduler.job import Job from apscheduler.job import Job
try: try:
import cPickle as pickle import pickle as pickle
except ImportError: # pragma: nocover except ImportError: # pragma: nocover
import pickle import pickle
try: try:
from redis import Redis from redis import StrictRedis
except ImportError: # pragma: nocover except ImportError: # pragma: nocover
raise ImportError('RedisJobStore requires redis installed') raise ImportError('RedisJobStore requires redis installed')
class RedisJobStore(BaseJobStore): class RedisJobStore(BaseJobStore):
""" """
Stores jobs in a Redis database. Any leftover keyword arguments are directly passed to redis's Stores jobs in a Redis database. Any leftover keyword arguments are directly passed to redis's StrictRedis.
:class:`~redis.StrictRedis`.
Plugin alias: ``redis`` Plugin alias: ``redis``
:param int db: the database number to store jobs in :param int db: the database number to store jobs in
:param str jobs_key: key to store jobs in :param str jobs_key: key to store jobs in
:param str run_times_key: key to store the jobs' run times in :param str run_times_key: key to store the jobs' run times in
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the :param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the highest available
highest available
""" """
def __init__(self, db=0, jobs_key='apscheduler.jobs', run_times_key='apscheduler.run_times', def __init__(self, db=0, jobs_key='apscheduler.jobs', run_times_key='apscheduler.run_times',
@@ -47,7 +43,7 @@ class RedisJobStore(BaseJobStore):
self.pickle_protocol = pickle_protocol self.pickle_protocol = pickle_protocol
self.jobs_key = jobs_key self.jobs_key = jobs_key
self.run_times_key = run_times_key self.run_times_key = run_times_key
self.redis = Redis(db=int(db), **connect_args) self.redis = StrictRedis(db=int(db), **connect_args)
def lookup_job(self, job_id): def lookup_job(self, job_id):
job_state = self.redis.hget(self.jobs_key, job_id) job_state = self.redis.hget(self.jobs_key, job_id)
@@ -69,8 +65,7 @@ class RedisJobStore(BaseJobStore):
def get_all_jobs(self): def get_all_jobs(self):
job_states = self.redis.hgetall(self.jobs_key) job_states = self.redis.hgetall(self.jobs_key)
jobs = self._reconstitute_jobs(six.iteritems(job_states)) jobs = self._reconstitute_jobs(six.iteritems(job_states))
paused_sort_key = datetime(9999, 12, 31, tzinfo=utc) return sorted(jobs, key=lambda job: job.next_run_time)
return sorted(jobs, key=lambda job: job.next_run_time or paused_sort_key)
def add_job(self, job): def add_job(self, job):
if self.redis.hexists(self.jobs_key, job.id): if self.redis.hexists(self.jobs_key, job.id):
@@ -78,12 +73,8 @@ class RedisJobStore(BaseJobStore):
with self.redis.pipeline() as pipe: with self.redis.pipeline() as pipe:
pipe.multi() pipe.multi()
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(), pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(), self.pickle_protocol))
self.pickle_protocol)) pipe.zadd(self.run_times_key, datetime_to_utc_timestamp(job.next_run_time), job.id)
if job.next_run_time:
pipe.zadd(self.run_times_key,
{job.id: datetime_to_utc_timestamp(job.next_run_time)})
pipe.execute() pipe.execute()
def update_job(self, job): def update_job(self, job):
@@ -91,14 +82,11 @@ class RedisJobStore(BaseJobStore):
raise JobLookupError(job.id) raise JobLookupError(job.id)
with self.redis.pipeline() as pipe: with self.redis.pipeline() as pipe:
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(), pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(), self.pickle_protocol))
self.pickle_protocol))
if job.next_run_time: if job.next_run_time:
pipe.zadd(self.run_times_key, pipe.zadd(self.run_times_key, datetime_to_utc_timestamp(job.next_run_time), job.id)
{job.id: datetime_to_utc_timestamp(job.next_run_time)})
else: else:
pipe.zrem(self.run_times_key, job.id) pipe.zrem(self.run_times_key, job.id)
pipe.execute() pipe.execute()
def remove_job(self, job_id): def remove_job(self, job_id):
@@ -133,7 +121,7 @@ class RedisJobStore(BaseJobStore):
for job_id, job_state in job_states: for job_id, job_state in job_states:
try: try:
jobs.append(self._reconstitute_job(job_state)) jobs.append(self._reconstitute_job(job_state))
except BaseException: except:
self._logger.exception('Unable to restore job "%s" -- removing it', job_id) self._logger.exception('Unable to restore job "%s" -- removing it', job_id)
failed_job_ids.append(job_id) failed_job_ids.append(job_id)
-155
View File
@@ -1,155 +0,0 @@
from __future__ import absolute_import
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
from apscheduler.job import Job
try:
import cPickle as pickle
except ImportError: # pragma: nocover
import pickle
try:
from rethinkdb import RethinkDB
except ImportError: # pragma: nocover
raise ImportError('RethinkDBJobStore requires rethinkdb installed')
class RethinkDBJobStore(BaseJobStore):
"""
Stores jobs in a RethinkDB database. Any leftover keyword arguments are directly passed to
rethinkdb's `RethinkdbClient <http://www.rethinkdb.com/api/#connect>`_.
Plugin alias: ``rethinkdb``
:param str database: database to store jobs in
:param str collection: collection to store jobs in
:param client: a :class:`rethinkdb.net.Connection` instance to use instead of providing
connection arguments
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the
highest available
"""
def __init__(self, database='apscheduler', table='jobs', client=None,
pickle_protocol=pickle.HIGHEST_PROTOCOL, **connect_args):
super(RethinkDBJobStore, self).__init__()
if not database:
raise ValueError('The "database" parameter must not be empty')
if not table:
raise ValueError('The "table" parameter must not be empty')
self.database = database
self.table_name = table
self.table = None
self.client = client
self.pickle_protocol = pickle_protocol
self.connect_args = connect_args
self.r = RethinkDB()
self.conn = None
def start(self, scheduler, alias):
super(RethinkDBJobStore, self).start(scheduler, alias)
if self.client:
self.conn = maybe_ref(self.client)
else:
self.conn = self.r.connect(db=self.database, **self.connect_args)
if self.database not in self.r.db_list().run(self.conn):
self.r.db_create(self.database).run(self.conn)
if self.table_name not in self.r.table_list().run(self.conn):
self.r.table_create(self.table_name).run(self.conn)
if 'next_run_time' not in self.r.table(self.table_name).index_list().run(self.conn):
self.r.table(self.table_name).index_create('next_run_time').run(self.conn)
self.table = self.r.db(self.database).table(self.table_name)
def lookup_job(self, job_id):
results = list(self.table.get_all(job_id).pluck('job_state').run(self.conn))
return self._reconstitute_job(results[0]['job_state']) if results else None
def get_due_jobs(self, now):
return self._get_jobs(self.r.row['next_run_time'] <= datetime_to_utc_timestamp(now))
def get_next_run_time(self):
results = list(
self.table
.filter(self.r.row['next_run_time'] != None) # noqa
.order_by(self.r.asc('next_run_time'))
.map(lambda x: x['next_run_time'])
.limit(1)
.run(self.conn)
)
return utc_timestamp_to_datetime(results[0]) if results else None
def get_all_jobs(self):
jobs = self._get_jobs()
self._fix_paused_jobs_sorting(jobs)
return jobs
def add_job(self, job):
job_dict = {
'id': job.id,
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
'job_state': self.r.binary(pickle.dumps(job.__getstate__(), self.pickle_protocol))
}
results = self.table.insert(job_dict).run(self.conn)
if results['errors'] > 0:
raise ConflictingIdError(job.id)
def update_job(self, job):
changes = {
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
'job_state': self.r.binary(pickle.dumps(job.__getstate__(), self.pickle_protocol))
}
results = self.table.get_all(job.id).update(changes).run(self.conn)
skipped = False in map(lambda x: results[x] == 0, results.keys())
if results['skipped'] > 0 or results['errors'] > 0 or not skipped:
raise JobLookupError(job.id)
def remove_job(self, job_id):
results = self.table.get_all(job_id).delete().run(self.conn)
if results['deleted'] + results['skipped'] != 1:
raise JobLookupError(job_id)
def remove_all_jobs(self):
self.table.delete().run(self.conn)
def shutdown(self):
self.conn.close()
def _reconstitute_job(self, job_state):
job_state = pickle.loads(job_state)
job = Job.__new__(Job)
job.__setstate__(job_state)
job._scheduler = self._scheduler
job._jobstore_alias = self._alias
return job
def _get_jobs(self, predicate=None):
jobs = []
failed_job_ids = []
query = (self.table.filter(self.r.row['next_run_time'] != None).filter(predicate) # noqa
if predicate else self.table)
query = query.order_by('next_run_time', 'id').pluck('id', 'job_state')
for document in query.run(self.conn):
try:
jobs.append(self._reconstitute_job(document['job_state']))
except Exception:
self._logger.exception('Unable to restore job "%s" -- removing it', document['id'])
failed_job_ids.append(document['id'])
# Remove all the jobs we failed to restore
if failed_job_ids:
self.r.expr(failed_job_ids).for_each(
lambda job_id: self.table.get_all(job_id).delete()).run(self.conn)
return jobs
def __repr__(self):
connection = self.conn
return '<%s (connection=%s)>' % (self.__class__.__name__, connection)
+45 -69
View File
@@ -1,47 +1,38 @@
from __future__ import absolute_import
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
from apscheduler.job import Job from apscheduler.job import Job
try: try:
import cPickle as pickle import pickle as pickle
except ImportError: # pragma: nocover except ImportError: # pragma: nocover
import pickle import pickle
try: try:
from sqlalchemy import ( from sqlalchemy import create_engine, Table, Column, MetaData, Unicode, Float, LargeBinary, select
create_engine, Table, Column, MetaData, Unicode, Float, LargeBinary, select, and_)
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.sql.expression import null
except ImportError: # pragma: nocover except ImportError: # pragma: nocover
raise ImportError('SQLAlchemyJobStore requires SQLAlchemy installed') raise ImportError('SQLAlchemyJobStore requires SQLAlchemy installed')
class SQLAlchemyJobStore(BaseJobStore): class SQLAlchemyJobStore(BaseJobStore):
""" """
Stores jobs in a database table using SQLAlchemy. Stores jobs in a database table using SQLAlchemy. The table will be created if it doesn't exist in the database.
The table will be created if it doesn't exist in the database.
Plugin alias: ``sqlalchemy`` Plugin alias: ``sqlalchemy``
:param str url: connection string (see :param str url: connection string (see `SQLAlchemy documentation
:ref:`SQLAlchemy documentation <sqlalchemy:database_urls>` on this) <http://docs.sqlalchemy.org/en/latest/core/engines.html?highlight=create_engine#database-urls>`_
:param engine: an SQLAlchemy :class:`~sqlalchemy.engine.Engine` to use instead of creating a on this)
new one based on ``url`` :param engine: an SQLAlchemy Engine to use instead of creating a new one based on ``url``
:param str tablename: name of the table to store jobs in :param str tablename: name of the table to store jobs in
:param metadata: a :class:`~sqlalchemy.schema.MetaData` instance to use instead of creating a :param metadata: a :class:`~sqlalchemy.MetaData` instance to use instead of creating a new one
new one :param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the highest available
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the
highest available
:param str tableschema: name of the (existing) schema in the target database where the table
should be
:param dict engine_options: keyword arguments to :func:`~sqlalchemy.create_engine`
(ignored if ``engine`` is given)
""" """
def __init__(self, url=None, engine=None, tablename='apscheduler_jobs', metadata=None, def __init__(self, url=None, engine=None, tablename='apscheduler_jobs', metadata=None,
pickle_protocol=pickle.HIGHEST_PROTOCOL, tableschema=None, engine_options=None): pickle_protocol=pickle.HIGHEST_PROTOCOL):
super(SQLAlchemyJobStore, self).__init__() super(SQLAlchemyJobStore, self).__init__()
self.pickle_protocol = pickle_protocol self.pickle_protocol = pickle_protocol
metadata = maybe_ref(metadata) or MetaData() metadata = maybe_ref(metadata) or MetaData()
@@ -49,46 +40,37 @@ class SQLAlchemyJobStore(BaseJobStore):
if engine: if engine:
self.engine = maybe_ref(engine) self.engine = maybe_ref(engine)
elif url: elif url:
self.engine = create_engine(url, **(engine_options or {})) self.engine = create_engine(url)
else: else:
raise ValueError('Need either "engine" or "url" defined') raise ValueError('Need either "engine" or "url" defined')
# 191 = max key length in MySQL for InnoDB/utf8mb4 tables, # 191 = max key length in MySQL for InnoDB/utf8mb4 tables, 25 = precision that translates to an 8-byte float
# 25 = precision that translates to an 8-byte float
self.jobs_t = Table( self.jobs_t = Table(
tablename, metadata, tablename, metadata,
Column('id', Unicode(191), primary_key=True), Column('id', Unicode(191, _warn_on_bytestring=False), primary_key=True),
Column('next_run_time', Float(25), index=True), Column('next_run_time', Float(25), index=True),
Column('job_state', LargeBinary, nullable=False), Column('job_state', LargeBinary, nullable=False)
schema=tableschema
) )
def start(self, scheduler, alias):
super(SQLAlchemyJobStore, self).start(scheduler, alias)
self.jobs_t.create(self.engine, True) self.jobs_t.create(self.engine, True)
def lookup_job(self, job_id): def lookup_job(self, job_id):
selectable = select(self.jobs_t.c.job_state).where(self.jobs_t.c.id == job_id) selectable = select([self.jobs_t.c.job_state]).where(self.jobs_t.c.id == job_id)
with self.engine.begin() as connection: job_state = self.engine.execute(selectable).scalar()
job_state = connection.execute(selectable).scalar() return self._reconstitute_job(job_state) if job_state else None
return self._reconstitute_job(job_state) if job_state else None
def get_due_jobs(self, now): def get_due_jobs(self, now):
timestamp = datetime_to_utc_timestamp(now) timestamp = datetime_to_utc_timestamp(now)
return self._get_jobs(self.jobs_t.c.next_run_time <= timestamp) return self._get_jobs(self.jobs_t.c.next_run_time <= timestamp)
def get_next_run_time(self): def get_next_run_time(self):
selectable = select(self.jobs_t.c.next_run_time).\ selectable = select([self.jobs_t.c.next_run_time]).where(self.jobs_t.c.next_run_time != None).\
where(self.jobs_t.c.next_run_time != null()).\
order_by(self.jobs_t.c.next_run_time).limit(1) order_by(self.jobs_t.c.next_run_time).limit(1)
with self.engine.begin() as connection: next_run_time = self.engine.execute(selectable).scalar()
next_run_time = connection.execute(selectable).scalar() return utc_timestamp_to_datetime(next_run_time)
return utc_timestamp_to_datetime(next_run_time)
def get_all_jobs(self): def get_all_jobs(self):
jobs = self._get_jobs() return self._get_jobs()
self._fix_paused_jobs_sorting(jobs)
return jobs
def add_job(self, job): def add_job(self, job):
insert = self.jobs_t.insert().values(**{ insert = self.jobs_t.insert().values(**{
@@ -96,33 +78,29 @@ class SQLAlchemyJobStore(BaseJobStore):
'next_run_time': datetime_to_utc_timestamp(job.next_run_time), 'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
'job_state': pickle.dumps(job.__getstate__(), self.pickle_protocol) 'job_state': pickle.dumps(job.__getstate__(), self.pickle_protocol)
}) })
with self.engine.begin() as connection: try:
try: self.engine.execute(insert)
connection.execute(insert) except IntegrityError:
except IntegrityError: raise ConflictingIdError(job.id)
raise ConflictingIdError(job.id)
def update_job(self, job): def update_job(self, job):
update = self.jobs_t.update().values(**{ update = self.jobs_t.update().values(**{
'next_run_time': datetime_to_utc_timestamp(job.next_run_time), 'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
'job_state': pickle.dumps(job.__getstate__(), self.pickle_protocol) 'job_state': pickle.dumps(job.__getstate__(), self.pickle_protocol)
}).where(self.jobs_t.c.id == job.id) }).where(self.jobs_t.c.id == job.id)
with self.engine.begin() as connection: result = self.engine.execute(update)
result = connection.execute(update) if result.rowcount == 0:
if result.rowcount == 0: raise JobLookupError(id)
raise JobLookupError(job.id)
def remove_job(self, job_id): def remove_job(self, job_id):
delete = self.jobs_t.delete().where(self.jobs_t.c.id == job_id) delete = self.jobs_t.delete().where(self.jobs_t.c.id == job_id)
with self.engine.begin() as connection: result = self.engine.execute(delete)
result = connection.execute(delete) if result.rowcount == 0:
if result.rowcount == 0: raise JobLookupError(job_id)
raise JobLookupError(job_id)
def remove_all_jobs(self): def remove_all_jobs(self):
delete = self.jobs_t.delete() delete = self.jobs_t.delete()
with self.engine.begin() as connection: self.engine.execute(delete)
connection.execute(delete)
def shutdown(self): def shutdown(self):
self.engine.dispose() self.engine.dispose()
@@ -138,22 +116,20 @@ class SQLAlchemyJobStore(BaseJobStore):
def _get_jobs(self, *conditions): def _get_jobs(self, *conditions):
jobs = [] jobs = []
selectable = select(self.jobs_t.c.id, self.jobs_t.c.job_state).\ selectable = select([self.jobs_t.c.id, self.jobs_t.c.job_state]).order_by(self.jobs_t.c.next_run_time)
order_by(self.jobs_t.c.next_run_time) selectable = selectable.where(*conditions) if conditions else selectable
selectable = selectable.where(and_(*conditions)) if conditions else selectable
failed_job_ids = set() failed_job_ids = set()
with self.engine.begin() as connection: for row in self.engine.execute(selectable):
for row in connection.execute(selectable): try:
try: jobs.append(self._reconstitute_job(row.job_state))
jobs.append(self._reconstitute_job(row.job_state)) except:
except BaseException: self._logger.exception('Unable to restore job "%s" -- removing it', row.id)
self._logger.exception('Unable to restore job "%s" -- removing it', row.id) failed_job_ids.add(row.id)
failed_job_ids.add(row.id)
# Remove all the jobs we failed to restore # Remove all the jobs we failed to restore
if failed_job_ids: if failed_job_ids:
delete = self.jobs_t.delete().where(self.jobs_t.c.id.in_(failed_job_ids)) delete = self.jobs_t.delete().where(self.jobs_t.c.id.in_(failed_job_ids))
connection.execute(delete) self.engine.execute(delete)
return jobs return jobs
-178
View File
@@ -1,178 +0,0 @@
from __future__ import absolute_import
from datetime import datetime
from pytz import utc
from kazoo.exceptions import NoNodeError, NodeExistsError
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
from apscheduler.job import Job
try:
import cPickle as pickle
except ImportError: # pragma: nocover
import pickle
try:
from kazoo.client import KazooClient
except ImportError: # pragma: nocover
raise ImportError('ZooKeeperJobStore requires Kazoo installed')
class ZooKeeperJobStore(BaseJobStore):
"""
Stores jobs in a ZooKeeper tree. Any leftover keyword arguments are directly passed to
kazoo's `KazooClient
<http://kazoo.readthedocs.io/en/latest/api/client.html>`_.
Plugin alias: ``zookeeper``
:param str path: path to store jobs in
:param client: a :class:`~kazoo.client.KazooClient` instance to use instead of
providing connection arguments
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the
highest available
"""
def __init__(self, path='/apscheduler', client=None, close_connection_on_exit=False,
pickle_protocol=pickle.HIGHEST_PROTOCOL, **connect_args):
super(ZooKeeperJobStore, self).__init__()
self.pickle_protocol = pickle_protocol
self.close_connection_on_exit = close_connection_on_exit
if not path:
raise ValueError('The "path" parameter must not be empty')
self.path = path
if client:
self.client = maybe_ref(client)
else:
self.client = KazooClient(**connect_args)
self._ensured_path = False
def _ensure_paths(self):
if not self._ensured_path:
self.client.ensure_path(self.path)
self._ensured_path = True
def start(self, scheduler, alias):
super(ZooKeeperJobStore, self).start(scheduler, alias)
if not self.client.connected:
self.client.start()
def lookup_job(self, job_id):
self._ensure_paths()
node_path = self.path + "/" + str(job_id)
try:
content, _ = self.client.get(node_path)
doc = pickle.loads(content)
job = self._reconstitute_job(doc['job_state'])
return job
except BaseException:
return None
def get_due_jobs(self, now):
timestamp = datetime_to_utc_timestamp(now)
jobs = [job_def['job'] for job_def in self._get_jobs()
if job_def['next_run_time'] is not None and job_def['next_run_time'] <= timestamp]
return jobs
def get_next_run_time(self):
next_runs = [job_def['next_run_time'] for job_def in self._get_jobs()
if job_def['next_run_time'] is not None]
return utc_timestamp_to_datetime(min(next_runs)) if len(next_runs) > 0 else None
def get_all_jobs(self):
jobs = [job_def['job'] for job_def in self._get_jobs()]
self._fix_paused_jobs_sorting(jobs)
return jobs
def add_job(self, job):
self._ensure_paths()
node_path = self.path + "/" + str(job.id)
value = {
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
'job_state': job.__getstate__()
}
data = pickle.dumps(value, self.pickle_protocol)
try:
self.client.create(node_path, value=data)
except NodeExistsError:
raise ConflictingIdError(job.id)
def update_job(self, job):
self._ensure_paths()
node_path = self.path + "/" + str(job.id)
changes = {
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
'job_state': job.__getstate__()
}
data = pickle.dumps(changes, self.pickle_protocol)
try:
self.client.set(node_path, value=data)
except NoNodeError:
raise JobLookupError(job.id)
def remove_job(self, job_id):
self._ensure_paths()
node_path = self.path + "/" + str(job_id)
try:
self.client.delete(node_path)
except NoNodeError:
raise JobLookupError(job_id)
def remove_all_jobs(self):
try:
self.client.delete(self.path, recursive=True)
except NoNodeError:
pass
self._ensured_path = False
def shutdown(self):
if self.close_connection_on_exit:
self.client.stop()
self.client.close()
def _reconstitute_job(self, job_state):
job_state = job_state
job = Job.__new__(Job)
job.__setstate__(job_state)
job._scheduler = self._scheduler
job._jobstore_alias = self._alias
return job
def _get_jobs(self):
self._ensure_paths()
jobs = []
failed_job_ids = []
all_ids = self.client.get_children(self.path)
for node_name in all_ids:
try:
node_path = self.path + "/" + node_name
content, _ = self.client.get(node_path)
doc = pickle.loads(content)
job_def = {
'job_id': node_name,
'next_run_time': doc['next_run_time'] if doc['next_run_time'] else None,
'job_state': doc['job_state'],
'job': self._reconstitute_job(doc['job_state']),
'creation_time': _.ctime
}
jobs.append(job_def)
except BaseException:
self._logger.exception('Unable to restore job "%s" -- removing it' % node_name)
failed_job_ids.append(node_name)
# Remove all the jobs we failed to restore
if failed_job_ids:
for failed_id in failed_job_ids:
self.remove_job(failed_id)
paused_sort_key = datetime(9999, 12, 31, tzinfo=utc)
return sorted(jobs, key=lambda job_def: (job_def['job'].next_run_time or paused_sort_key,
job_def['creation_time']))
def __repr__(self):
self._logger.exception('<%s (client=%s)>' % (self.__class__.__name__, self.client))
return '<%s (client=%s)>' % (self.__class__.__name__, self.client)
+15 -13
View File
@@ -1,16 +1,22 @@
from __future__ import absolute_import
import asyncio from functools import wraps
from functools import wraps, partial
from apscheduler.schedulers.base import BaseScheduler from apscheduler.schedulers.base import BaseScheduler
from apscheduler.util import maybe_ref from apscheduler.util import maybe_ref
try:
import asyncio
except ImportError: # pragma: nocover
try:
import trollius as asyncio
except ImportError:
raise ImportError('AsyncIOScheduler requires either Python 3.4 or the asyncio package installed')
def run_in_event_loop(func): def run_in_event_loop(func):
@wraps(func) @wraps(func)
def wrapper(self, *args, **kwargs): def wrapper(self, *args, **kwargs):
wrapped = partial(func, self, *args, **kwargs) self._eventloop.call_soon_threadsafe(func, self, *args, **kwargs)
self._eventloop.call_soon_threadsafe(wrapped)
return wrapper return wrapper
@@ -18,8 +24,6 @@ class AsyncIOScheduler(BaseScheduler):
""" """
A scheduler that runs on an asyncio (:pep:`3156`) event loop. A scheduler that runs on an asyncio (:pep:`3156`) event loop.
The default executor can run jobs based on native coroutines (``async def``).
Extra options: Extra options:
============== ============================================================= ============== =============================================================
@@ -30,11 +34,9 @@ class AsyncIOScheduler(BaseScheduler):
_eventloop = None _eventloop = None
_timeout = None _timeout = None
def start(self, paused=False): def start(self):
if not self._eventloop: super(AsyncIOScheduler, self).start()
self._eventloop = asyncio.get_event_loop() self.wakeup()
super(AsyncIOScheduler, self).start(paused)
@run_in_event_loop @run_in_event_loop
def shutdown(self, wait=True): def shutdown(self, wait=True):
@@ -42,7 +44,7 @@ class AsyncIOScheduler(BaseScheduler):
self._stop_timer() self._stop_timer()
def _configure(self, config): def _configure(self, config):
self._eventloop = maybe_ref(config.pop('event_loop', None)) self._eventloop = maybe_ref(config.pop('event_loop', None)) or asyncio.get_event_loop()
super(AsyncIOScheduler, self)._configure(config) super(AsyncIOScheduler, self)._configure(config)
def _start_timer(self, wait_seconds): def _start_timer(self, wait_seconds):
+9 -13
View File
@@ -1,4 +1,3 @@
from __future__ import absolute_import
from threading import Thread, Event from threading import Thread, Event
@@ -14,12 +13,11 @@ class BackgroundScheduler(BlockingScheduler):
Extra options: Extra options:
========== ============================================================================= ========== ============================================================================================
``daemon`` Set the ``daemon`` option in the background thread (defaults to ``True``, see ``daemon`` Set the ``daemon`` option in the background thread (defaults to ``True``,
`the documentation see `the documentation <https://docs.python.org/3.4/library/threading.html#thread-objects>`_
<https://docs.python.org/3.4/library/threading.html#thread-objects>`_
for further details) for further details)
========== ============================================================================= ========== ============================================================================================
""" """
_thread = None _thread = None
@@ -28,16 +26,14 @@ class BackgroundScheduler(BlockingScheduler):
self._daemon = asbool(config.pop('daemon', True)) self._daemon = asbool(config.pop('daemon', True))
super(BackgroundScheduler, self)._configure(config) super(BackgroundScheduler, self)._configure(config)
def start(self, *args, **kwargs): def start(self):
if self._event is None or self._event.is_set(): BaseScheduler.start(self)
self._event = Event() self._event = Event()
BaseScheduler.start(self, *args, **kwargs)
self._thread = Thread(target=self._main_loop, name='APScheduler') self._thread = Thread(target=self._main_loop, name='APScheduler')
self._thread.daemon = self._daemon self._thread.daemon = self._daemon
self._thread.start() self._thread.start()
def shutdown(self, *args, **kwargs): def shutdown(self, wait=True):
super(BackgroundScheduler, self).shutdown(*args, **kwargs) super(BackgroundScheduler, self).shutdown(wait)
self._thread.join() self._thread.join()
del self._thread del self._thread
File diff suppressed because it is too large Load Diff
+11 -14
View File
@@ -1,23 +1,21 @@
from __future__ import absolute_import
from threading import Event from threading import Event
from apscheduler.schedulers.base import BaseScheduler, STATE_STOPPED from apscheduler.schedulers.base import BaseScheduler
from apscheduler.util import TIMEOUT_MAX
class BlockingScheduler(BaseScheduler): class BlockingScheduler(BaseScheduler):
""" """
A scheduler that runs in the foreground A scheduler that runs in the foreground (:meth:`~apscheduler.schedulers.base.BaseScheduler.start` will block).
(:meth:`~apscheduler.schedulers.base.BaseScheduler.start` will block).
""" """
MAX_WAIT_TIME = 4294967 # Maximum value accepted by Event.wait() on Windows
_event = None _event = None
def start(self, *args, **kwargs): def start(self):
if self._event is None or self._event.is_set(): super(BlockingScheduler, self).start()
self._event = Event() self._event = Event()
super(BlockingScheduler, self).start(*args, **kwargs)
self._main_loop() self._main_loop()
def shutdown(self, wait=True): def shutdown(self, wait=True):
@@ -25,11 +23,10 @@ class BlockingScheduler(BaseScheduler):
self._event.set() self._event.set()
def _main_loop(self): def _main_loop(self):
wait_seconds = TIMEOUT_MAX while self.running:
while self.state != STATE_STOPPED:
self._event.wait(wait_seconds)
self._event.clear()
wait_seconds = self._process_jobs() wait_seconds = self._process_jobs()
self._event.wait(wait_seconds if wait_seconds is not None else self.MAX_WAIT_TIME)
self._event.clear()
def wakeup(self): def wakeup(self):
self._event.set() self._event.set()
+5 -5
View File
@@ -1,4 +1,4 @@
from __future__ import absolute_import
from apscheduler.schedulers.blocking import BlockingScheduler from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.schedulers.base import BaseScheduler from apscheduler.schedulers.base import BaseScheduler
@@ -16,14 +16,14 @@ class GeventScheduler(BlockingScheduler):
_greenlet = None _greenlet = None
def start(self, *args, **kwargs): def start(self):
BaseScheduler.start(self)
self._event = Event() self._event = Event()
BaseScheduler.start(self, *args, **kwargs)
self._greenlet = gevent.spawn(self._main_loop) self._greenlet = gevent.spawn(self._main_loop)
return self._greenlet return self._greenlet
def shutdown(self, *args, **kwargs): def shutdown(self, wait=True):
super(GeventScheduler, self).shutdown(*args, **kwargs) super(GeventScheduler, self).shutdown(wait)
self._greenlet.join() self._greenlet.join()
del self._greenlet del self._greenlet
+11 -15
View File
@@ -1,24 +1,17 @@
from __future__ import absolute_import
from apscheduler.schedulers.base import BaseScheduler from apscheduler.schedulers.base import BaseScheduler
try: try:
from PyQt5.QtCore import QObject, QTimer from PyQt5.QtCore import QObject, QTimer
except (ImportError, RuntimeError): # pragma: nocover except ImportError: # pragma: nocover
try: try:
from PyQt4.QtCore import QObject, QTimer from PyQt4.QtCore import QObject, QTimer
except ImportError: except ImportError:
try: try:
from PySide6.QtCore import QObject, QTimer # noqa from PySide.QtCore import QObject, QTimer # flake8: noqa
except ImportError: except ImportError:
try: raise ImportError('QtScheduler requires either PyQt5, PyQt4 or PySide installed')
from PySide2.QtCore import QObject, QTimer # noqa
except ImportError:
try:
from PySide.QtCore import QObject, QTimer # noqa
except ImportError:
raise ImportError('QtScheduler requires either PyQt5, PyQt4, PySide6, PySide2 '
'or PySide installed')
class QtScheduler(BaseScheduler): class QtScheduler(BaseScheduler):
@@ -26,15 +19,18 @@ class QtScheduler(BaseScheduler):
_timer = None _timer = None
def shutdown(self, *args, **kwargs): def start(self):
super(QtScheduler, self).shutdown(*args, **kwargs) super(QtScheduler, self).start()
self.wakeup()
def shutdown(self, wait=True):
super(QtScheduler, self).shutdown(wait)
self._stop_timer() self._stop_timer()
def _start_timer(self, wait_seconds): def _start_timer(self, wait_seconds):
self._stop_timer() self._stop_timer()
if wait_seconds is not None: if wait_seconds is not None:
wait_time = min(int(wait_seconds * 1000), 2147483647) self._timer = QTimer.singleShot(wait_seconds * 1000, self._process_jobs)
self._timer = QTimer.singleShot(wait_time, self._process_jobs)
def _stop_timer(self): def _stop_timer(self):
if self._timer: if self._timer:
+4 -7
View File
@@ -1,4 +1,3 @@
from __future__ import absolute_import
from datetime import timedelta from datetime import timedelta
from functools import wraps from functools import wraps
@@ -23,8 +22,6 @@ class TornadoScheduler(BaseScheduler):
""" """
A scheduler that runs on a Tornado IOLoop. A scheduler that runs on a Tornado IOLoop.
The default executor can run jobs based on native coroutines (``async def``).
=========== =============================================================== =========== ===============================================================
``io_loop`` Tornado IOLoop instance to use (defaults to the global IO loop) ``io_loop`` Tornado IOLoop instance to use (defaults to the global IO loop)
=========== =============================================================== =========== ===============================================================
@@ -33,6 +30,10 @@ class TornadoScheduler(BaseScheduler):
_ioloop = None _ioloop = None
_timeout = None _timeout = None
def start(self):
super(TornadoScheduler, self).start()
self.wakeup()
@run_in_ioloop @run_in_ioloop
def shutdown(self, wait=True): def shutdown(self, wait=True):
super(TornadoScheduler, self).shutdown(wait) super(TornadoScheduler, self).shutdown(wait)
@@ -52,10 +53,6 @@ class TornadoScheduler(BaseScheduler):
self._ioloop.remove_timeout(self._timeout) self._ioloop.remove_timeout(self._timeout)
del self._timeout del self._timeout
def _create_default_executor(self):
from apscheduler.executors.tornado import TornadoExecutor
return TornadoExecutor()
@run_in_ioloop @run_in_ioloop
def wakeup(self): def wakeup(self):
self._stop_timer() self._stop_timer()
+4 -1
View File
@@ -1,4 +1,3 @@
from __future__ import absolute_import
from functools import wraps from functools import wraps
@@ -36,6 +35,10 @@ class TwistedScheduler(BaseScheduler):
self._reactor = maybe_ref(config.pop('reactor', default_reactor)) self._reactor = maybe_ref(config.pop('reactor', default_reactor))
super(TwistedScheduler, self)._configure(config) super(TwistedScheduler, self)._configure(config)
def start(self):
super(TwistedScheduler, self).start()
self.wakeup()
@run_in_reactor @run_in_reactor
def shutdown(self, wait=True): def shutdown(self, wait=True):
super(TwistedScheduler, self).shutdown(wait) super(TwistedScheduler, self).shutdown(wait)
+1 -22
View File
@@ -1,6 +1,4 @@
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
from datetime import timedelta
import random
import six import six
@@ -8,30 +6,11 @@ import six
class BaseTrigger(six.with_metaclass(ABCMeta)): class BaseTrigger(six.with_metaclass(ABCMeta)):
"""Abstract base class that defines the interface that every trigger must implement.""" """Abstract base class that defines the interface that every trigger must implement."""
__slots__ = ()
@abstractmethod @abstractmethod
def get_next_fire_time(self, previous_fire_time, now): def get_next_fire_time(self, previous_fire_time, now):
""" """
Returns the next datetime to fire on, If no such datetime can be calculated, returns Returns the next datetime to fire on, If no such datetime can be calculated, returns ``None``.
``None``.
:param datetime.datetime previous_fire_time: the previous time the trigger was fired :param datetime.datetime previous_fire_time: the previous time the trigger was fired
:param datetime.datetime now: current datetime :param datetime.datetime now: current datetime
""" """
def _apply_jitter(self, next_fire_time, jitter, now):
"""
Randomize ``next_fire_time`` by adding a random value (the jitter).
:param datetime.datetime|None next_fire_time: next fire time without jitter applied. If
``None``, returns ``None``.
:param int|None jitter: maximum number of seconds to add to ``next_fire_time``
(if ``None`` or ``0``, returns ``next_fire_time``)
:param datetime.datetime now: current datetime
:return datetime.datetime|None: next fire time with a jitter.
"""
if next_fire_time is None or not jitter:
return next_fire_time
return next_fire_time + timedelta(seconds=random.uniform(0, jitter))
-95
View File
@@ -1,95 +0,0 @@
from apscheduler.triggers.base import BaseTrigger
from apscheduler.util import obj_to_ref, ref_to_obj
class BaseCombiningTrigger(BaseTrigger):
__slots__ = ('triggers', 'jitter')
def __init__(self, triggers, jitter=None):
self.triggers = triggers
self.jitter = jitter
def __getstate__(self):
return {
'version': 1,
'triggers': [(obj_to_ref(trigger.__class__), trigger.__getstate__())
for trigger in self.triggers],
'jitter': self.jitter
}
def __setstate__(self, state):
if state.get('version', 1) > 1:
raise ValueError(
'Got serialized data for version %s of %s, but only versions up to 1 can be '
'handled' % (state['version'], self.__class__.__name__))
self.jitter = state['jitter']
self.triggers = []
for clsref, state in state['triggers']:
cls = ref_to_obj(clsref)
trigger = cls.__new__(cls)
trigger.__setstate__(state)
self.triggers.append(trigger)
def __repr__(self):
return '<{}({}{})>'.format(self.__class__.__name__, self.triggers,
', jitter={}'.format(self.jitter) if self.jitter else '')
class AndTrigger(BaseCombiningTrigger):
"""
Always returns the earliest next fire time that all the given triggers can agree on.
The trigger is considered to be finished when any of the given triggers has finished its
schedule.
Trigger alias: ``and``
:param list triggers: triggers to combine
:param int|None jitter: delay the job execution by ``jitter`` seconds at most
"""
__slots__ = ()
def get_next_fire_time(self, previous_fire_time, now):
while True:
fire_times = [trigger.get_next_fire_time(previous_fire_time, now)
for trigger in self.triggers]
if None in fire_times:
return None
elif min(fire_times) == max(fire_times):
return self._apply_jitter(fire_times[0], self.jitter, now)
else:
now = max(fire_times)
def __str__(self):
return 'and[{}]'.format(', '.join(str(trigger) for trigger in self.triggers))
class OrTrigger(BaseCombiningTrigger):
"""
Always returns the earliest next fire time produced by any of the given triggers.
The trigger is considered finished when all the given triggers have finished their schedules.
Trigger alias: ``or``
:param list triggers: triggers to combine
:param int|None jitter: delay the job execution by ``jitter`` seconds at most
.. note:: Triggers that depends on the previous fire time, such as the interval trigger, may
seem to behave strangely since they are always passed the previous fire time produced by
any of the given triggers.
"""
__slots__ = ()
def get_next_fire_time(self, previous_fire_time, now):
fire_times = [trigger.get_next_fire_time(previous_fire_time, now)
for trigger in self.triggers]
fire_times = [fire_time for fire_time in fire_times if fire_time is not None]
if fire_times:
return self._apply_jitter(min(fire_times), self.jitter, now)
else:
return None
def __str__(self):
return 'or[{}]'.format(', '.join(str(trigger) for trigger in self.triggers))
+21 -84
View File
@@ -4,20 +4,17 @@ from tzlocal import get_localzone
import six import six
from apscheduler.triggers.base import BaseTrigger from apscheduler.triggers.base import BaseTrigger
from apscheduler.triggers.cron.fields import ( from apscheduler.triggers.cron.fields import BaseField, WeekField, DayOfMonthField, DayOfWeekField, DEFAULT_VALUES
BaseField, MonthField, WeekField, DayOfMonthField, DayOfWeekField, DEFAULT_VALUES) from apscheduler.util import datetime_ceil, convert_to_datetime, datetime_repr, astimezone
from apscheduler.util import (
datetime_ceil, convert_to_datetime, datetime_repr, astimezone, localize, normalize)
class CronTrigger(BaseTrigger): class CronTrigger(BaseTrigger):
""" """
Triggers when current time matches all specified time constraints, Triggers when current time matches all specified time constraints, similarly to how the UNIX cron scheduler works.
similarly to how the UNIX cron scheduler works.
:param int|str year: 4-digit year :param int|str year: 4-digit year
:param int|str month: month (1-12) :param int|str month: month (1-12)
:param int|str day: day of month (1-31) :param int|str day: day of the (1-31)
:param int|str week: ISO week (1-53) :param int|str week: ISO week (1-53)
:param int|str day_of_week: number or name of weekday (0-6 or mon,tue,wed,thu,fri,sat,sun) :param int|str day_of_week: number or name of weekday (0-6 or mon,tue,wed,thu,fri,sat,sun)
:param int|str hour: hour (0-23) :param int|str hour: hour (0-23)
@@ -25,9 +22,8 @@ class CronTrigger(BaseTrigger):
:param int|str second: second (0-59) :param int|str second: second (0-59)
:param datetime|str start_date: earliest possible date/time to trigger on (inclusive) :param datetime|str start_date: earliest possible date/time to trigger on (inclusive)
:param datetime|str end_date: latest possible date/time to trigger on (inclusive) :param datetime|str end_date: latest possible date/time to trigger on (inclusive)
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations (defaults :param datetime.tzinfo|str timezone: time zone to use for the date/time calculations
to scheduler timezone) (defaults to scheduler timezone)
:param int|None jitter: delay the job execution by ``jitter`` seconds at most
.. note:: The first weekday is always **monday**. .. note:: The first weekday is always **monday**.
""" """
@@ -35,7 +31,7 @@ class CronTrigger(BaseTrigger):
FIELD_NAMES = ('year', 'month', 'day', 'week', 'day_of_week', 'hour', 'minute', 'second') FIELD_NAMES = ('year', 'month', 'day', 'week', 'day_of_week', 'hour', 'minute', 'second')
FIELDS_MAP = { FIELDS_MAP = {
'year': BaseField, 'year': BaseField,
'month': MonthField, 'month': BaseField,
'week': WeekField, 'week': WeekField,
'day': DayOfMonthField, 'day': DayOfMonthField,
'day_of_week': DayOfWeekField, 'day_of_week': DayOfWeekField,
@@ -44,16 +40,15 @@ class CronTrigger(BaseTrigger):
'second': BaseField 'second': BaseField
} }
__slots__ = 'timezone', 'start_date', 'end_date', 'fields', 'jitter' __slots__ = 'timezone', 'start_date', 'end_date', 'fields'
def __init__(self, year=None, month=None, day=None, week=None, day_of_week=None, hour=None, def __init__(self, year=None, month=None, day=None, week=None, day_of_week=None, hour=None, minute=None,
minute=None, second=None, start_date=None, end_date=None, timezone=None, second=None, start_date=None, end_date=None, timezone=None):
jitter=None):
if timezone: if timezone:
self.timezone = astimezone(timezone) self.timezone = astimezone(timezone)
elif isinstance(start_date, datetime) and start_date.tzinfo: elif start_date and start_date.tzinfo:
self.timezone = start_date.tzinfo self.timezone = start_date.tzinfo
elif isinstance(end_date, datetime) and end_date.tzinfo: elif end_date and end_date.tzinfo:
self.timezone = end_date.tzinfo self.timezone = end_date.tzinfo
else: else:
self.timezone = get_localzone() self.timezone = get_localzone()
@@ -61,8 +56,6 @@ class CronTrigger(BaseTrigger):
self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date') self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date')
self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date') self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date')
self.jitter = jitter
values = dict((key, value) for (key, value) in six.iteritems(locals()) values = dict((key, value) for (key, value) in six.iteritems(locals())
if key in self.FIELD_NAMES and value is not None) if key in self.FIELD_NAMES and value is not None)
self.fields = [] self.fields = []
@@ -83,35 +76,13 @@ class CronTrigger(BaseTrigger):
field = field_class(field_name, exprs, is_default) field = field_class(field_name, exprs, is_default)
self.fields.append(field) self.fields.append(field)
@classmethod
def from_crontab(cls, expr, timezone=None):
"""
Create a :class:`~CronTrigger` from a standard crontab expression.
See https://en.wikipedia.org/wiki/Cron for more information on the format accepted here.
:param expr: minute, hour, day of month, month, day of week
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations (
defaults to scheduler timezone)
:return: a :class:`~CronTrigger` instance
"""
values = expr.split()
if len(values) != 5:
raise ValueError('Wrong number of fields; got {}, expected 5'.format(len(values)))
return cls(minute=values[0], hour=values[1], day=values[2], month=values[3],
day_of_week=values[4], timezone=timezone)
def _increment_field_value(self, dateval, fieldnum): def _increment_field_value(self, dateval, fieldnum):
""" """
Increments the designated field and resets all less significant fields to their minimum Increments the designated field and resets all less significant fields to their minimum values.
values.
:type dateval: datetime :type dateval: datetime
:type fieldnum: int :type fieldnum: int
:return: a tuple containing the new date, and the number of the field that was actually :return: a tuple containing the new date, and the number of the field that was actually incremented
incremented
:rtype: tuple :rtype: tuple
""" """
@@ -144,7 +115,7 @@ class CronTrigger(BaseTrigger):
i += 1 i += 1
difference = datetime(**values) - dateval.replace(tzinfo=None) difference = datetime(**values) - dateval.replace(tzinfo=None)
return normalize(dateval + difference), fieldnum return self.timezone.normalize(dateval + difference), fieldnum
def _set_field_value(self, dateval, fieldnum, new_value): def _set_field_value(self, dateval, fieldnum, new_value):
values = {} values = {}
@@ -157,13 +128,12 @@ class CronTrigger(BaseTrigger):
else: else:
values[field.name] = new_value values[field.name] = new_value
return localize(datetime(**values), self.timezone) difference = datetime(**values) - dateval.replace(tzinfo=None)
return self.timezone.normalize(dateval + difference)
def get_next_fire_time(self, previous_fire_time, now): def get_next_fire_time(self, previous_fire_time, now):
if previous_fire_time: if previous_fire_time:
start_date = min(now, previous_fire_time + timedelta(microseconds=1)) start_date = max(now, previous_fire_time + timedelta(microseconds=1))
if start_date == previous_fire_time:
start_date += timedelta(microseconds=1)
else: else:
start_date = max(now, self.start_date) if self.start_date else now start_date = max(now, self.start_date) if self.start_date else now
@@ -193,34 +163,7 @@ class CronTrigger(BaseTrigger):
return None return None
if fieldnum >= 0: if fieldnum >= 0:
next_date = self._apply_jitter(next_date, self.jitter, now) return next_date
return min(next_date, self.end_date) if self.end_date else next_date
def __getstate__(self):
return {
'version': 2,
'timezone': self.timezone,
'start_date': self.start_date,
'end_date': self.end_date,
'fields': self.fields,
'jitter': self.jitter,
}
def __setstate__(self, state):
# This is for compatibility with APScheduler 3.0.x
if isinstance(state, tuple):
state = state[1]
if state.get('version', 1) > 2:
raise ValueError(
'Got serialized data for version %s of %s, but only versions up to 2 can be '
'handled' % (state['version'], self.__class__.__name__))
self.timezone = state['timezone']
self.start_date = state['start_date']
self.end_date = state['end_date']
self.fields = state['fields']
self.jitter = state.get('jitter')
def __str__(self): def __str__(self):
options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default] options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default]
@@ -229,11 +172,5 @@ class CronTrigger(BaseTrigger):
def __repr__(self): def __repr__(self):
options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default] options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default]
if self.start_date: if self.start_date:
options.append("start_date=%r" % datetime_repr(self.start_date)) options.append("start_date='%s'" % datetime_repr(self.start_date))
if self.end_date: return '<%s (%s)>' % (self.__class__.__name__, ', '.join(options))
options.append("end_date=%r" % datetime_repr(self.end_date))
if self.jitter:
options.append('jitter=%s' % self.jitter)
return "<%s (%s, timezone='%s')>" % (
self.__class__.__name__, ', '.join(options), self.timezone)
+24 -87
View File
@@ -1,16 +1,17 @@
"""This module contains the expressions applicable for CronTrigger's fields.""" """
This module contains the expressions applicable for CronTrigger's fields.
"""
from calendar import monthrange from calendar import monthrange
import re import re
from apscheduler.util import asint from apscheduler.util import asint
__all__ = ('AllExpression', 'RangeExpression', 'WeekdayRangeExpression', __all__ = ('AllExpression', 'RangeExpression', 'WeekdayRangeExpression', 'WeekdayPositionExpression',
'WeekdayPositionExpression', 'LastDayOfMonthExpression') 'LastDayOfMonthExpression')
WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
class AllExpression(object): class AllExpression(object):
@@ -21,14 +22,6 @@ class AllExpression(object):
if self.step == 0: if self.step == 0:
raise ValueError('Increment must be higher than 0') raise ValueError('Increment must be higher than 0')
def validate_range(self, field_name):
from apscheduler.triggers.cron.fields import MIN_VALUES, MAX_VALUES
value_range = MAX_VALUES[field_name] - MIN_VALUES[field_name]
if self.step and self.step > value_range:
raise ValueError('the step value ({}) is higher than the total range of the '
'expression ({})'.format(self.step, value_range))
def get_next_value(self, date, field): def get_next_value(self, date, field):
start = field.get_value(date) start = field.get_value(date)
minval = field.get_min(date) minval = field.get_min(date)
@@ -44,9 +37,6 @@ class AllExpression(object):
if next <= maxval: if next <= maxval:
return next return next
def __eq__(self, other):
return isinstance(other, self.__class__) and self.step == other.step
def __str__(self): def __str__(self):
if self.step: if self.step:
return '*/%d' % self.step return '*/%d' % self.step
@@ -61,7 +51,7 @@ class RangeExpression(AllExpression):
r'(?P<first>\d+)(?:-(?P<last>\d+))?(?:/(?P<step>\d+))?$') r'(?P<first>\d+)(?:-(?P<last>\d+))?(?:/(?P<step>\d+))?$')
def __init__(self, first, last=None, step=None): def __init__(self, first, last=None, step=None):
super(RangeExpression, self).__init__(step) AllExpression.__init__(self, step)
first = asint(first) first = asint(first)
last = asint(last) last = asint(last)
if last is None and step is None: if last is None and step is None:
@@ -71,41 +61,25 @@ class RangeExpression(AllExpression):
self.first = first self.first = first
self.last = last self.last = last
def validate_range(self, field_name):
from apscheduler.triggers.cron.fields import MIN_VALUES, MAX_VALUES
super(RangeExpression, self).validate_range(field_name)
if self.first < MIN_VALUES[field_name]:
raise ValueError('the first value ({}) is lower than the minimum value ({})'
.format(self.first, MIN_VALUES[field_name]))
if self.last is not None and self.last > MAX_VALUES[field_name]:
raise ValueError('the last value ({}) is higher than the maximum value ({})'
.format(self.last, MAX_VALUES[field_name]))
value_range = (self.last or MAX_VALUES[field_name]) - self.first
if self.step and self.step > value_range:
raise ValueError('the step value ({}) is higher than the total range of the '
'expression ({})'.format(self.step, value_range))
def get_next_value(self, date, field): def get_next_value(self, date, field):
startval = field.get_value(date) start = field.get_value(date)
minval = field.get_min(date) minval = field.get_min(date)
maxval = field.get_max(date) maxval = field.get_max(date)
# Apply range limits # Apply range limits
minval = max(minval, self.first) minval = max(minval, self.first)
maxval = min(maxval, self.last) if self.last is not None else maxval if self.last is not None:
nextval = max(minval, startval) maxval = min(maxval, self.last)
start = max(start, minval)
# Apply the step if defined if not self.step:
if self.step: next = start
distance_to_next = (self.step - (nextval - minval)) % self.step else:
nextval += distance_to_next distance_to_next = (self.step - (start - minval)) % self.step
next = start + distance_to_next
return nextval if nextval <= maxval else None if next <= maxval:
return next
def __eq__(self, other):
return (isinstance(other, self.__class__) and self.first == other.first and
self.last == other.last)
def __str__(self): def __str__(self):
if self.last != self.first and self.last is not None: if self.last != self.first and self.last is not None:
@@ -126,37 +100,6 @@ class RangeExpression(AllExpression):
return "%s(%s)" % (self.__class__.__name__, ', '.join(args)) return "%s(%s)" % (self.__class__.__name__, ', '.join(args))
class MonthRangeExpression(RangeExpression):
value_re = re.compile(r'(?P<first>[a-z]+)(?:-(?P<last>[a-z]+))?', re.IGNORECASE)
def __init__(self, first, last=None):
try:
first_num = MONTHS.index(first.lower()) + 1
except ValueError:
raise ValueError('Invalid month name "%s"' % first)
if last:
try:
last_num = MONTHS.index(last.lower()) + 1
except ValueError:
raise ValueError('Invalid month name "%s"' % last)
else:
last_num = None
super(MonthRangeExpression, self).__init__(first_num, last_num)
def __str__(self):
if self.last != self.first and self.last is not None:
return '%s-%s' % (MONTHS[self.first - 1], MONTHS[self.last - 1])
return MONTHS[self.first - 1]
def __repr__(self):
args = ["'%s'" % MONTHS[self.first]]
if self.last != self.first and self.last is not None:
args.append("'%s'" % MONTHS[self.last - 1])
return "%s(%s)" % (self.__class__.__name__, ', '.join(args))
class WeekdayRangeExpression(RangeExpression): class WeekdayRangeExpression(RangeExpression):
value_re = re.compile(r'(?P<first>[a-z]+)(?:-(?P<last>[a-z]+))?', re.IGNORECASE) value_re = re.compile(r'(?P<first>[a-z]+)(?:-(?P<last>[a-z]+))?', re.IGNORECASE)
@@ -174,7 +117,7 @@ class WeekdayRangeExpression(RangeExpression):
else: else:
last_num = None last_num = None
super(WeekdayRangeExpression, self).__init__(first_num, last_num) RangeExpression.__init__(self, first_num, last_num)
def __str__(self): def __str__(self):
if self.last != self.first and self.last is not None: if self.last != self.first and self.last is not None:
@@ -190,11 +133,9 @@ class WeekdayRangeExpression(RangeExpression):
class WeekdayPositionExpression(AllExpression): class WeekdayPositionExpression(AllExpression):
options = ['1st', '2nd', '3rd', '4th', '5th', 'last'] options = ['1st', '2nd', '3rd', '4th', '5th', 'last']
value_re = re.compile(r'(?P<option_name>%s) +(?P<weekday_name>(?:\d+|\w+))' % value_re = re.compile(r'(?P<option_name>%s) +(?P<weekday_name>(?:\d+|\w+))' % '|'.join(options), re.IGNORECASE)
'|'.join(options), re.IGNORECASE)
def __init__(self, option_name, weekday_name): def __init__(self, option_name, weekday_name):
super(WeekdayPositionExpression, self).__init__(None)
try: try:
self.option_num = self.options.index(option_name.lower()) self.option_num = self.options.index(option_name.lower())
except ValueError: except ValueError:
@@ -206,7 +147,8 @@ class WeekdayPositionExpression(AllExpression):
raise ValueError('Invalid weekday name "%s"' % weekday_name) raise ValueError('Invalid weekday name "%s"' % weekday_name)
def get_next_value(self, date, field): def get_next_value(self, date, field):
# Figure out the weekday of the month's first day and the number of days in that month # Figure out the weekday of the month's first day and the number
# of days in that month
first_day_wday, last_day = monthrange(date.year, date.month) first_day_wday, last_day = monthrange(date.year, date.month)
# Calculate which day of the month is the first of the target weekdays # Calculate which day of the month is the first of the target weekdays
@@ -218,28 +160,23 @@ class WeekdayPositionExpression(AllExpression):
if self.option_num < 5: if self.option_num < 5:
target_day = first_hit_day + self.option_num * 7 target_day = first_hit_day + self.option_num * 7
else: else:
target_day = first_hit_day + ((last_day - first_hit_day) // 7) * 7 target_day = first_hit_day + ((last_day - first_hit_day) / 7) * 7
if target_day <= last_day and target_day >= date.day: if target_day <= last_day and target_day >= date.day:
return target_day return target_day
def __eq__(self, other):
return (super(WeekdayPositionExpression, self).__eq__(other) and
self.option_num == other.option_num and self.weekday == other.weekday)
def __str__(self): def __str__(self):
return '%s %s' % (self.options[self.option_num], WEEKDAYS[self.weekday]) return '%s %s' % (self.options[self.option_num], WEEKDAYS[self.weekday])
def __repr__(self): def __repr__(self):
return "%s('%s', '%s')" % (self.__class__.__name__, self.options[self.option_num], return "%s('%s', '%s')" % (self.__class__.__name__, self.options[self.option_num], WEEKDAYS[self.weekday])
WEEKDAYS[self.weekday])
class LastDayOfMonthExpression(AllExpression): class LastDayOfMonthExpression(AllExpression):
value_re = re.compile(r'last', re.IGNORECASE) value_re = re.compile(r'last', re.IGNORECASE)
def __init__(self): def __init__(self):
super(LastDayOfMonthExpression, self).__init__(None) pass
def get_next_value(self, date, field): def get_next_value(self, date, field):
return monthrange(date.year, date.month)[1] return monthrange(date.year, date.month)[1]
+17 -31
View File
@@ -1,26 +1,22 @@
"""Fields represent CronTrigger options which map to :class:`~datetime.datetime` fields.""" """
Fields represent CronTrigger options which map to :class:`~datetime.datetime`
fields.
"""
from calendar import monthrange from calendar import monthrange
import re
import six
from apscheduler.triggers.cron.expressions import ( from apscheduler.triggers.cron.expressions import (
AllExpression, RangeExpression, WeekdayPositionExpression, LastDayOfMonthExpression, AllExpression, RangeExpression, WeekdayPositionExpression, LastDayOfMonthExpression, WeekdayRangeExpression)
WeekdayRangeExpression, MonthRangeExpression)
__all__ = ('MIN_VALUES', 'MAX_VALUES', 'DEFAULT_VALUES', 'BaseField', 'WeekField', __all__ = ('MIN_VALUES', 'MAX_VALUES', 'DEFAULT_VALUES', 'BaseField', 'WeekField', 'DayOfMonthField', 'DayOfWeekField')
'DayOfMonthField', 'DayOfWeekField')
MIN_VALUES = {'year': 1970, 'month': 1, 'day': 1, 'week': 1, 'day_of_week': 0, 'hour': 0, MIN_VALUES = {'year': 1970, 'month': 1, 'day': 1, 'week': 1, 'day_of_week': 0, 'hour': 0, 'minute': 0, 'second': 0}
'minute': 0, 'second': 0} MAX_VALUES = {'year': 2 ** 63, 'month': 12, 'day:': 31, 'week': 53, 'day_of_week': 6, 'hour': 23, 'minute': 59,
MAX_VALUES = {'year': 9999, 'month': 12, 'day': 31, 'week': 53, 'day_of_week': 6, 'hour': 23, 'second': 59}
'minute': 59, 'second': 59} DEFAULT_VALUES = {'year': '*', 'month': 1, 'day': 1, 'week': '*', 'day_of_week': '*', 'hour': 0, 'minute': 0,
DEFAULT_VALUES = {'year': '*', 'month': 1, 'day': 1, 'week': '*', 'day_of_week': '*', 'hour': 0, 'second': 0}
'minute': 0, 'second': 0}
SEPARATOR = re.compile(' *, *')
class BaseField(object): class BaseField(object):
@@ -54,29 +50,23 @@ class BaseField(object):
self.expressions = [] self.expressions = []
# Split a comma-separated expression list, if any # Split a comma-separated expression list, if any
for expr in SEPARATOR.split(str(exprs).strip()): exprs = str(exprs).strip()
self.compile_expression(expr) if ',' in exprs:
for expr in exprs.split(','):
self.compile_expression(expr)
else:
self.compile_expression(exprs)
def compile_expression(self, expr): def compile_expression(self, expr):
for compiler in self.COMPILERS: for compiler in self.COMPILERS:
match = compiler.value_re.match(expr) match = compiler.value_re.match(expr)
if match: if match:
compiled_expr = compiler(**match.groupdict()) compiled_expr = compiler(**match.groupdict())
try:
compiled_expr.validate_range(self.name)
except ValueError as e:
exc = ValueError('Error validating expression {!r}: {}'.format(expr, e))
six.raise_from(exc, None)
self.expressions.append(compiled_expr) self.expressions.append(compiled_expr)
return return
raise ValueError('Unrecognized expression "%s" for field "%s"' % (expr, self.name)) raise ValueError('Unrecognized expression "%s" for field "%s"' % (expr, self.name))
def __eq__(self, other):
return isinstance(self, self.__class__) and self.expressions == other.expressions
def __str__(self): def __str__(self):
expr_strings = (str(e) for e in self.expressions) expr_strings = (str(e) for e in self.expressions)
return ','.join(expr_strings) return ','.join(expr_strings)
@@ -105,7 +95,3 @@ class DayOfWeekField(BaseField):
def get_value(self, dateval): def get_value(self, dateval):
return dateval.weekday() return dateval.weekday()
class MonthField(BaseField):
COMPILERS = BaseField.COMPILERS + [MonthRangeExpression]
+2 -23
View File
@@ -14,36 +14,15 @@ class DateTrigger(BaseTrigger):
:param datetime.tzinfo|str timezone: time zone for ``run_date`` if it doesn't have one already :param datetime.tzinfo|str timezone: time zone for ``run_date`` if it doesn't have one already
""" """
__slots__ = 'run_date' __slots__ = 'timezone', 'run_date'
def __init__(self, run_date=None, timezone=None): def __init__(self, run_date=None, timezone=None):
timezone = astimezone(timezone) or get_localzone() timezone = astimezone(timezone) or get_localzone()
if run_date is not None: self.run_date = convert_to_datetime(run_date or datetime.now(), timezone, 'run_date')
self.run_date = convert_to_datetime(run_date, timezone, 'run_date')
else:
self.run_date = datetime.now(timezone)
def get_next_fire_time(self, previous_fire_time, now): def get_next_fire_time(self, previous_fire_time, now):
return self.run_date if previous_fire_time is None else None return self.run_date if previous_fire_time is None else None
def __getstate__(self):
return {
'version': 1,
'run_date': self.run_date
}
def __setstate__(self, state):
# This is for compatibility with APScheduler 3.0.x
if isinstance(state, tuple):
state = state[1]
if state.get('version', 1) > 1:
raise ValueError(
'Got serialized data for version %s of %s, but only version 1 can be handled' %
(state['version'], self.__class__.__name__))
self.run_date = state['run_date']
def __str__(self): def __str__(self):
return 'date[%s]' % datetime_repr(self.run_date) return 'date[%s]' % datetime_repr(self.run_date)
+11 -54
View File
@@ -4,15 +4,13 @@ from math import ceil
from tzlocal import get_localzone from tzlocal import get_localzone
from apscheduler.triggers.base import BaseTrigger from apscheduler.triggers.base import BaseTrigger
from apscheduler.util import ( from apscheduler.util import convert_to_datetime, timedelta_seconds, datetime_repr, astimezone
convert_to_datetime, normalize, timedelta_seconds, datetime_repr,
astimezone)
class IntervalTrigger(BaseTrigger): class IntervalTrigger(BaseTrigger):
""" """
Triggers on specified intervals, starting on ``start_date`` if specified, ``datetime.now()`` + Triggers on specified intervals, starting on ``start_date`` if specified, ``datetime.now()`` + interval
interval otherwise. otherwise.
:param int weeks: number of weeks to wait :param int weeks: number of weeks to wait
:param int days: number of days to wait :param int days: number of days to wait
@@ -22,15 +20,12 @@ class IntervalTrigger(BaseTrigger):
:param datetime|str start_date: starting point for the interval calculation :param datetime|str start_date: starting point for the interval calculation
:param datetime|str end_date: latest possible date/time to trigger on :param datetime|str end_date: latest possible date/time to trigger on
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations :param datetime.tzinfo|str timezone: time zone to use for the date/time calculations
:param int|None jitter: delay the job execution by ``jitter`` seconds at most
""" """
__slots__ = 'timezone', 'start_date', 'end_date', 'interval', 'interval_length', 'jitter' __slots__ = 'timezone', 'start_date', 'end_date', 'interval'
def __init__(self, weeks=0, days=0, hours=0, minutes=0, seconds=0, start_date=None, def __init__(self, weeks=0, days=0, hours=0, minutes=0, seconds=0, start_date=None, end_date=None, timezone=None):
end_date=None, timezone=None, jitter=None): self.interval = timedelta(weeks=weeks, days=days, hours=hours, minutes=minutes, seconds=seconds)
self.interval = timedelta(weeks=weeks, days=days, hours=hours, minutes=minutes,
seconds=seconds)
self.interval_length = timedelta_seconds(self.interval) self.interval_length = timedelta_seconds(self.interval)
if self.interval_length == 0: if self.interval_length == 0:
self.interval = timedelta(seconds=1) self.interval = timedelta(seconds=1)
@@ -38,9 +33,9 @@ class IntervalTrigger(BaseTrigger):
if timezone: if timezone:
self.timezone = astimezone(timezone) self.timezone = astimezone(timezone)
elif isinstance(start_date, datetime) and start_date.tzinfo: elif start_date and start_date.tzinfo:
self.timezone = start_date.tzinfo self.timezone = start_date.tzinfo
elif isinstance(end_date, datetime) and end_date.tzinfo: elif end_date and end_date.tzinfo:
self.timezone = end_date.tzinfo self.timezone = end_date.tzinfo
else: else:
self.timezone = get_localzone() self.timezone = get_localzone()
@@ -49,8 +44,6 @@ class IntervalTrigger(BaseTrigger):
self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date') self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date')
self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date') self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date')
self.jitter = jitter
def get_next_fire_time(self, previous_fire_time, now): def get_next_fire_time(self, previous_fire_time, now):
if previous_fire_time: if previous_fire_time:
next_fire_time = previous_fire_time + self.interval next_fire_time = previous_fire_time + self.interval
@@ -61,48 +54,12 @@ class IntervalTrigger(BaseTrigger):
next_interval_num = int(ceil(timediff_seconds / self.interval_length)) next_interval_num = int(ceil(timediff_seconds / self.interval_length))
next_fire_time = self.start_date + self.interval * next_interval_num next_fire_time = self.start_date + self.interval * next_interval_num
if self.jitter is not None:
next_fire_time = self._apply_jitter(next_fire_time, self.jitter, now)
if not self.end_date or next_fire_time <= self.end_date: if not self.end_date or next_fire_time <= self.end_date:
return normalize(next_fire_time) return self.timezone.normalize(next_fire_time)
def __getstate__(self):
return {
'version': 2,
'timezone': self.timezone,
'start_date': self.start_date,
'end_date': self.end_date,
'interval': self.interval,
'jitter': self.jitter,
}
def __setstate__(self, state):
# This is for compatibility with APScheduler 3.0.x
if isinstance(state, tuple):
state = state[1]
if state.get('version', 1) > 2:
raise ValueError(
'Got serialized data for version %s of %s, but only versions up to 2 can be '
'handled' % (state['version'], self.__class__.__name__))
self.timezone = state['timezone']
self.start_date = state['start_date']
self.end_date = state['end_date']
self.interval = state['interval']
self.interval_length = timedelta_seconds(self.interval)
self.jitter = state.get('jitter')
def __str__(self): def __str__(self):
return 'interval[%s]' % str(self.interval) return 'interval[%s]' % str(self.interval)
def __repr__(self): def __repr__(self):
options = ['interval=%r' % self.interval, 'start_date=%r' % datetime_repr(self.start_date)] return "<%s (interval=%r, start_date='%s')>" % (self.__class__.__name__, self.interval,
if self.end_date: datetime_repr(self.start_date))
options.append("end_date=%r" % datetime_repr(self.end_date))
if self.jitter:
options.append('jitter=%s' % self.jitter)
return "<%s (%s, timezone='%s')>" % (
self.__class__.__name__, ', '.join(options), self.timezone)
+111 -156
View File
@@ -1,36 +1,29 @@
"""This module contains several handy functions primarily meant for internal use.""" """This module contains several handy functions primarily meant for internal use."""
from __future__ import division
from asyncio import iscoroutinefunction
from datetime import date, datetime, time, timedelta, tzinfo from datetime import date, datetime, time, timedelta, tzinfo
from inspect import isfunction, ismethod, getargspec
from calendar import timegm from calendar import timegm
from functools import partial
from inspect import isclass, ismethod
import re import re
import sys
from pytz import timezone, utc, FixedOffset from pytz import timezone, utc
import six import six
try: try:
from inspect import signature from inspect import signature
except ImportError: # pragma: nocover except ImportError: # pragma: nocover
from funcsigs import signature try:
from funcsigs import signature
try: except ImportError:
from threading import TIMEOUT_MAX signature = None
except ImportError:
TIMEOUT_MAX = 4294967 # Maximum value accepted by Event.wait() on Windows
__all__ = ('asint', 'asbool', 'astimezone', 'convert_to_datetime', 'datetime_to_utc_timestamp', __all__ = ('asint', 'asbool', 'astimezone', 'convert_to_datetime', 'datetime_to_utc_timestamp',
'utc_timestamp_to_datetime', 'timedelta_seconds', 'datetime_ceil', 'get_callable_name', 'utc_timestamp_to_datetime', 'timedelta_seconds', 'datetime_ceil', 'get_callable_name', 'obj_to_ref',
'obj_to_ref', 'ref_to_obj', 'maybe_ref', 'repr_escape', 'check_callable_args', 'ref_to_obj', 'maybe_ref', 'repr_escape', 'check_callable_args')
'normalize', 'localize', 'TIMEOUT_MAX')
class _Undefined(object): class _Undefined(object):
def __nonzero__(self): def __bool__(self):
return False return False
def __bool__(self): def __bool__(self):
@@ -39,18 +32,17 @@ class _Undefined(object):
def __repr__(self): def __repr__(self):
return '<undefined>' return '<undefined>'
undefined = _Undefined() #: a unique object that only signifies that no value is defined undefined = _Undefined() #: a unique object that only signifies that no value is defined
def asint(text): def asint(text):
""" """
Safely converts a string to an integer, returning ``None`` if the string is ``None``. Safely converts a string to an integer, returning None if the string is None.
:type text: str :type text: str
:rtype: int :rtype: int
""" """
if text is not None: if text is not None:
return int(text) return int(text)
@@ -60,8 +52,8 @@ def asbool(obj):
Interprets an object as a boolean value. Interprets an object as a boolean value.
:rtype: bool :rtype: bool
""" """
if isinstance(obj, str): if isinstance(obj, str):
obj = obj.strip().lower() obj = obj.strip().lower()
if obj in ('true', 'yes', 'on', 'y', 't', '1'): if obj in ('true', 'yes', 'on', 'y', 't', '1'):
@@ -77,17 +69,15 @@ def astimezone(obj):
Interprets an object as a timezone. Interprets an object as a timezone.
:rtype: tzinfo :rtype: tzinfo
""" """
if isinstance(obj, six.string_types): if isinstance(obj, six.string_types):
return timezone(obj) return timezone(obj)
if isinstance(obj, tzinfo): if isinstance(obj, tzinfo):
if obj.tzname(None) == 'local': if not hasattr(obj, 'localize') or not hasattr(obj, 'normalize'):
raise ValueError( raise TypeError('Only timezones from the pytz library are supported')
'Unable to determine the name of the local timezone -- you must explicitly ' if obj.zone == 'local':
'specify the name of the local timezone. Please refrain from using timezones like ' raise ValueError('Unable to determine the name of the local timezone -- use an explicit timezone instead')
'EST to prevent problems with daylight saving time. Instead, use a locale based '
'timezone name (such as Europe/Helsinki).')
return obj return obj
if obj is not None: if obj is not None:
raise TypeError('Expected tzinfo, got %s instead' % obj.__class__.__name__) raise TypeError('Expected tzinfo, got %s instead' % obj.__class__.__name__)
@@ -95,30 +85,27 @@ def astimezone(obj):
_DATE_REGEX = re.compile( _DATE_REGEX = re.compile(
r'(?P<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})' r'(?P<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})'
r'(?:[ T](?P<hour>\d{1,2}):(?P<minute>\d{1,2}):(?P<second>\d{1,2})' r'(?: (?P<hour>\d{1,2}):(?P<minute>\d{1,2}):(?P<second>\d{1,2})'
r'(?:\.(?P<microsecond>\d{1,6}))?' r'(?:\.(?P<microsecond>\d{1,6}))?)?')
r'(?P<timezone>Z|[+-]\d\d:\d\d)?)?$')
def convert_to_datetime(input, tz, arg_name): def convert_to_datetime(input, tz, arg_name):
""" """
Converts the given object to a timezone aware datetime object. Converts the given object to a timezone aware datetime object.
If a timezone aware datetime object is passed, it is returned unmodified. If a timezone aware datetime object is passed, it is returned unmodified.
If a native datetime object is passed, it is given the specified timezone. If a native datetime object is passed, it is given the specified timezone.
If the input is a string, it is parsed as a datetime with the given timezone. If the input is a string, it is parsed as a datetime with the given timezone.
Date strings are accepted in three different forms: date only (Y-m-d), date with time Date strings are accepted in three different forms: date only (Y-m-d),
(Y-m-d H:M:S) or with date+time with microseconds (Y-m-d H:M:S.micro). Additionally you can date with time (Y-m-d H:M:S) or with date+time with microseconds
override the time zone by giving a specific offset in the format specified by ISO 8601: (Y-m-d H:M:S.micro).
Z (UTC), +HH:MM or -HH:MM.
:param str|datetime input: the datetime or string to convert to a timezone aware datetime :param str|datetime input: the datetime or string to convert to a timezone aware datetime
:param datetime.tzinfo tz: timezone to interpret ``input`` in :param datetime.tzinfo tz: timezone to interpret ``input`` in
:param str arg_name: the name of the argument (used in an error message) :param str arg_name: the name of the argument (used in an error message)
:rtype: datetime :rtype: datetime
""" """
if input is None: if input is None:
return return
elif isinstance(input, datetime): elif isinstance(input, datetime):
@@ -129,17 +116,8 @@ def convert_to_datetime(input, tz, arg_name):
m = _DATE_REGEX.match(input) m = _DATE_REGEX.match(input)
if not m: if not m:
raise ValueError('Invalid date string') raise ValueError('Invalid date string')
values = [(k, int(v or 0)) for k, v in list(m.groupdict().items())]
values = m.groupdict() values = dict(values)
tzname = values.pop('timezone')
if tzname == 'Z':
tz = utc
elif tzname:
hours, minutes = (int(x) for x in tzname[1:].split(':'))
sign = 1 if tzname[0] == '+' else -1
tz = FixedOffset(sign * (hours * 60 + minutes))
values = {k: int(v or 0) for k, v in values.items()}
datetime_ = datetime(**values) datetime_ = datetime(**values)
else: else:
raise TypeError('Unsupported type for %s: %s' % (arg_name, input.__class__.__name__)) raise TypeError('Unsupported type for %s: %s' % (arg_name, input.__class__.__name__))
@@ -147,12 +125,14 @@ def convert_to_datetime(input, tz, arg_name):
if datetime_.tzinfo is not None: if datetime_.tzinfo is not None:
return datetime_ return datetime_
if tz is None: if tz is None:
raise ValueError( raise ValueError('The "tz" argument must be specified if %s has no timezone information' % arg_name)
'The "tz" argument must be specified if %s has no timezone information' % arg_name)
if isinstance(tz, six.string_types): if isinstance(tz, six.string_types):
tz = timezone(tz) tz = timezone(tz)
return localize(datetime_, tz) try:
return tz.localize(datetime_, is_dst=None)
except AttributeError:
raise TypeError('Only pytz timezones are supported (need the localize() and normalize() methods)')
def datetime_to_utc_timestamp(timeval): def datetime_to_utc_timestamp(timeval):
@@ -161,8 +141,8 @@ def datetime_to_utc_timestamp(timeval):
:type timeval: datetime :type timeval: datetime
:rtype: float :rtype: float
""" """
if timeval is not None: if timeval is not None:
return timegm(timeval.utctimetuple()) + timeval.microsecond / 1000000 return timegm(timeval.utctimetuple()) + timeval.microsecond / 1000000
@@ -173,8 +153,8 @@ def utc_timestamp_to_datetime(timestamp):
:type timestamp: float :type timestamp: float
:rtype: datetime :rtype: datetime
""" """
if timestamp is not None: if timestamp is not None:
return datetime.fromtimestamp(timestamp, utc) return datetime.fromtimestamp(timestamp, utc)
@@ -185,8 +165,8 @@ def timedelta_seconds(delta):
:type delta: timedelta :type delta: timedelta
:rtype: float :rtype: float
""" """
return delta.days * 24 * 60 * 60 + delta.seconds + \ return delta.days * 24 * 60 * 60 + delta.seconds + \
delta.microseconds / 1000000.0 delta.microseconds / 1000000.0
@@ -196,8 +176,8 @@ def datetime_ceil(dateval):
Rounds the given datetime object upwards. Rounds the given datetime object upwards.
:type dateval: datetime :type dateval: datetime
""" """
if dateval.microsecond > 0: if dateval.microsecond > 0:
return dateval + timedelta(seconds=1, microseconds=-dateval.microsecond) return dateval + timedelta(seconds=1, microseconds=-dateval.microsecond)
return dateval return dateval
@@ -212,8 +192,8 @@ def get_callable_name(func):
Returns the best available display name for the given function/callable. Returns the best available display name for the given function/callable.
:rtype: str :rtype: str
""" """
# the easy case (on Python 3.3+) # the easy case (on Python 3.3+)
if hasattr(func, '__qualname__'): if hasattr(func, '__qualname__'):
return func.__qualname__ return func.__qualname__
@@ -221,7 +201,7 @@ def get_callable_name(func):
# class methods, bound and unbound methods # class methods, bound and unbound methods
f_self = getattr(func, '__self__', None) or getattr(func, 'im_self', None) f_self = getattr(func, '__self__', None) or getattr(func, 'im_self', None)
if f_self and hasattr(func, '__name__'): if f_self and hasattr(func, '__name__'):
f_class = f_self if isclass(f_self) else f_self.__class__ f_class = f_self if isinstance(f_self, type) else f_self.__class__
else: else:
f_class = getattr(func, 'im_class', None) f_class = getattr(func, 'im_class', None)
@@ -242,35 +222,20 @@ def get_callable_name(func):
def obj_to_ref(obj): def obj_to_ref(obj):
""" """
Returns the path to the given callable. Returns the path to the given object.
:rtype: str :rtype: str
:raises TypeError: if the given object is not callable
:raises ValueError: if the given object is a :class:`~functools.partial`, lambda or a nested
function
""" """
if isinstance(obj, partial):
raise ValueError('Cannot create a reference to a partial()')
name = get_callable_name(obj) try:
if '<lambda>' in name: ref = '%s:%s' % (obj.__module__, get_callable_name(obj))
raise ValueError('Cannot create a reference to a lambda') obj2 = ref_to_obj(ref)
if '<locals>' in name: if obj != obj2:
raise ValueError('Cannot create a reference to a nested function') raise ValueError
except Exception:
raise ValueError('Cannot determine the reference to %r' % obj)
if ismethod(obj): return ref
if hasattr(obj, 'im_self') and obj.im_self:
# bound method
module = obj.im_self.__module__
elif hasattr(obj, 'im_class') and obj.im_class:
# unbound method
module = obj.im_class.__module__
else:
module = obj.__module__
else:
module = obj.__module__
return '%s:%s' % (module, name)
def ref_to_obj(ref): def ref_to_obj(ref):
@@ -278,8 +243,8 @@ def ref_to_obj(ref):
Returns the object pointed to by ``ref``. Returns the object pointed to by ``ref``.
:type ref: str :type ref: str
""" """
if not isinstance(ref, six.string_types): if not isinstance(ref, six.string_types):
raise TypeError('References must be strings') raise TypeError('References must be strings')
if ':' not in ref: if ':' not in ref:
@@ -287,12 +252,12 @@ def ref_to_obj(ref):
modulename, rest = ref.split(':', 1) modulename, rest = ref.split(':', 1)
try: try:
obj = __import__(modulename, fromlist=[rest]) obj = __import__(modulename)
except ImportError: except ImportError:
raise LookupError('Error resolving reference %s: could not import module' % ref) raise LookupError('Error resolving reference %s: could not import module' % ref)
try: try:
for name in rest.split('.'): for name in modulename.split('.')[1:] + rest.split('.'):
obj = getattr(obj, name) obj = getattr(obj, name)
return obj return obj
except Exception: except Exception:
@@ -303,8 +268,8 @@ def maybe_ref(ref):
""" """
Returns the object that the given reference points to, if it is indeed a reference. Returns the object that the given reference points to, if it is indeed a reference.
If it is not a reference, the object is returned as-is. If it is not a reference, the object is returned as-is.
""" """
if not isinstance(ref, str): if not isinstance(ref, str):
return ref return ref
return ref_to_obj(ref) return ref_to_obj(ref)
@@ -316,8 +281,7 @@ if six.PY2:
return string.encode('ascii', 'backslashreplace') return string.encode('ascii', 'backslashreplace')
return string return string
else: else:
def repr_escape(string): repr_escape = lambda string: string
return string
def check_callable_args(func, args, kwargs): def check_callable_args(func, args, kwargs):
@@ -326,54 +290,70 @@ def check_callable_args(func, args, kwargs):
:type args: tuple :type args: tuple
:type kwargs: dict :type kwargs: dict
""" """
pos_kwargs_conflicts = [] # parameters that have a match in both args and kwargs pos_kwargs_conflicts = [] # parameters that have a match in both args and kwargs
positional_only_kwargs = [] # positional-only parameters that have a match in kwargs positional_only_kwargs = [] # positional-only parameters that have a match in kwargs
unsatisfied_args = [] # parameters in signature that don't have a match in args or kwargs unsatisfied_args = [] # parameters in signature that don't have a match in args or kwargs
unsatisfied_kwargs = [] # keyword-only arguments that don't have a match in kwargs unsatisfied_kwargs = [] # keyword-only arguments that don't have a match in kwargs
unmatched_args = list(args) # args that didn't match any of the parameters in the signature unmatched_args = list(args) # args that didn't match any of the parameters in the signature
# kwargs that didn't match any of the parameters in the signature unmatched_kwargs = list(kwargs) # kwargs that didn't match any of the parameters in the signature
unmatched_kwargs = list(kwargs) has_varargs = has_var_kwargs = False # indicates if the signature defines *args and **kwargs respectively
# indicates if the signature defines *args and **kwargs respectively
has_varargs = has_var_kwargs = False
try: if signature:
if sys.version_info >= (3, 5): try:
sig = signature(func, follow_wrapped=False)
else:
sig = signature(func) sig = signature(func)
except ValueError: except ValueError:
# signature() doesn't work against every kind of callable return # signature() doesn't work against every kind of callable
return
for param in six.itervalues(sig.parameters): for param in six.itervalues(sig.parameters):
if param.kind == param.POSITIONAL_OR_KEYWORD: if param.kind == param.POSITIONAL_OR_KEYWORD:
if param.name in unmatched_kwargs and unmatched_args: if param.name in unmatched_kwargs and unmatched_args:
pos_kwargs_conflicts.append(param.name) pos_kwargs_conflicts.append(param.name)
elif unmatched_args:
del unmatched_args[0]
elif param.name in unmatched_kwargs:
unmatched_kwargs.remove(param.name)
elif param.default is param.empty:
unsatisfied_args.append(param.name)
elif param.kind == param.POSITIONAL_ONLY:
if unmatched_args:
del unmatched_args[0]
elif param.name in unmatched_kwargs:
unmatched_kwargs.remove(param.name)
positional_only_kwargs.append(param.name)
elif param.default is param.empty:
unsatisfied_args.append(param.name)
elif param.kind == param.KEYWORD_ONLY:
if param.name in unmatched_kwargs:
unmatched_kwargs.remove(param.name)
elif param.default is param.empty:
unsatisfied_kwargs.append(param.name)
elif param.kind == param.VAR_POSITIONAL:
has_varargs = True
elif param.kind == param.VAR_KEYWORD:
has_var_kwargs = True
else:
if not isfunction(func) and not ismethod(func) and hasattr(func, '__call__'):
func = func.__call__
try:
argspec = getargspec(func)
except TypeError:
return # getargspec() doesn't work certain callables
argspec_args = argspec.args if not ismethod(func) else argspec.args[1:]
has_varargs = bool(argspec.varargs)
has_var_kwargs = bool(argspec.keywords)
for arg, default in six.moves.zip_longest(argspec_args, argspec.defaults or (), fillvalue=undefined):
if arg in unmatched_kwargs and unmatched_args:
pos_kwargs_conflicts.append(arg)
elif unmatched_args: elif unmatched_args:
del unmatched_args[0] del unmatched_args[0]
elif param.name in unmatched_kwargs: elif arg in unmatched_kwargs:
unmatched_kwargs.remove(param.name) unmatched_kwargs.remove(arg)
elif param.default is param.empty: elif default is undefined:
unsatisfied_args.append(param.name) unsatisfied_args.append(arg)
elif param.kind == param.POSITIONAL_ONLY:
if unmatched_args:
del unmatched_args[0]
elif param.name in unmatched_kwargs:
unmatched_kwargs.remove(param.name)
positional_only_kwargs.append(param.name)
elif param.default is param.empty:
unsatisfied_args.append(param.name)
elif param.kind == param.KEYWORD_ONLY:
if param.name in unmatched_kwargs:
unmatched_kwargs.remove(param.name)
elif param.default is param.empty:
unsatisfied_kwargs.append(param.name)
elif param.kind == param.VAR_POSITIONAL:
has_varargs = True
elif param.kind == param.VAR_KEYWORD:
has_var_kwargs = True
# Make sure there are no conflicts between args and kwargs # Make sure there are no conflicts between args and kwargs
if pos_kwargs_conflicts: if pos_kwargs_conflicts:
@@ -385,46 +365,21 @@ def check_callable_args(func, args, kwargs):
raise ValueError('The following arguments cannot be given as keyword arguments: %s' % raise ValueError('The following arguments cannot be given as keyword arguments: %s' %
', '.join(positional_only_kwargs)) ', '.join(positional_only_kwargs))
# Check that the number of positional arguments minus the number of matched kwargs matches the # Check that the number of positional arguments minus the number of matched kwargs matches the argspec
# argspec
if unsatisfied_args: if unsatisfied_args:
raise ValueError('The following arguments have not been supplied: %s' % raise ValueError('The following arguments have not been supplied: %s' % ', '.join(unsatisfied_args))
', '.join(unsatisfied_args))
# Check that all keyword-only arguments have been supplied # Check that all keyword-only arguments have been supplied
if unsatisfied_kwargs: if unsatisfied_kwargs:
raise ValueError( raise ValueError('The following keyword-only arguments have not been supplied in kwargs: %s' %
'The following keyword-only arguments have not been supplied in kwargs: %s' % ', '.join(unsatisfied_kwargs))
', '.join(unsatisfied_kwargs))
# Check that the callable can accept the given number of positional arguments # Check that the callable can accept the given number of positional arguments
if not has_varargs and unmatched_args: if not has_varargs and unmatched_args:
raise ValueError( raise ValueError('The list of positional arguments is longer than the target callable can handle '
'The list of positional arguments is longer than the target callable can handle ' '(allowed: %d, given in args: %d)' % (len(args) - len(unmatched_args), len(args)))
'(allowed: %d, given in args: %d)' % (len(args) - len(unmatched_args), len(args)))
# Check that the callable can accept the given keyword arguments # Check that the callable can accept the given keyword arguments
if not has_var_kwargs and unmatched_kwargs: if not has_var_kwargs and unmatched_kwargs:
raise ValueError( raise ValueError('The target callable does not accept the following keyword arguments: %s' %
'The target callable does not accept the following keyword arguments: %s' % ', '.join(unmatched_kwargs))
', '.join(unmatched_kwargs))
def iscoroutinefunction_partial(f):
while isinstance(f, partial):
f = f.func
# The asyncio version of iscoroutinefunction includes testing for @coroutine
# decorations vs. the inspect version which does not.
return iscoroutinefunction(f)
def normalize(dt):
return datetime.fromtimestamp(dt.timestamp(), dt.tzinfo)
def localize(dt, tzinfo):
if hasattr(tzinfo, 'localize'):
return tzinfo.localize(dt)
return normalize(dt.replace(tzinfo=tzinfo))
+7 -8
View File
@@ -13,29 +13,28 @@
# included in all copies or substantial portions of the Software. # included in all copies or substantial portions of the Software.
import confuse
from sys import stderr from sys import stderr
import confuse __version__ = '1.6.0'
__author__ = 'Adrian Sampson <adrian@radbox.org>'
__version__ = "2.0.0"
__author__ = "Adrian Sampson <adrian@radbox.org>"
class IncludeLazyConfig(confuse.LazyConfig): class IncludeLazyConfig(confuse.LazyConfig):
"""A version of Confuse's LazyConfig that also merges in data from """A version of Confuse's LazyConfig that also merges in data from
YAML files specified in an `include` setting. YAML files specified in an `include` setting.
""" """
def read(self, user=True, defaults=True): def read(self, user=True, defaults=True):
super().read(user, defaults) super().read(user, defaults)
try: try:
for view in self["include"]: for view in self['include']:
self.set_file(view.as_filename()) self.set_file(view.as_filename())
except confuse.NotFoundError: except confuse.NotFoundError:
pass pass
except confuse.ConfigReadError as err: except confuse.ConfigReadError as err:
stderr.write("configuration `import` failed: {}".format(err.reason)) stderr.write("configuration `import` failed: {}"
.format(err.reason))
config = IncludeLazyConfig("beets", __name__) config = IncludeLazyConfig('beets', __name__)
-1
View File
@@ -18,7 +18,6 @@
import sys import sys
from .ui import main from .ui import main
if __name__ == "__main__": if __name__ == "__main__":
+112 -108
View File
@@ -17,19 +17,21 @@ music and items' embedded album art.
""" """
import os import subprocess
import platform
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
import os
import mediafile from beets.util import displayable_path, syspath, bytestring_path
from beets.util import bytestring_path, displayable_path, syspath
from beets.util.artresizer import ArtResizer from beets.util.artresizer import ArtResizer
import mediafile
def mediafile_image(image_path, maxwidth=None): def mediafile_image(image_path, maxwidth=None):
"""Return a `mediafile.Image` object for the path.""" """Return a `mediafile.Image` object for the path.
"""
with open(syspath(image_path), "rb") as f: with open(syspath(image_path), 'rb') as f:
data = f.read() data = f.read()
return mediafile.Image(data, type=mediafile.ImageType.front) return mediafile.Image(data, type=mediafile.ImageType.front)
@@ -39,168 +41,170 @@ def get_art(log, item):
try: try:
mf = mediafile.MediaFile(syspath(item.path)) mf = mediafile.MediaFile(syspath(item.path))
except mediafile.UnreadableFileError as exc: except mediafile.UnreadableFileError as exc:
log.warning( log.warning('Could not extract art from {0}: {1}',
"Could not extract art from {0}: {1}", displayable_path(item.path), exc)
displayable_path(item.path),
exc,
)
return return
return mf.art return mf.art
def embed_item( def embed_item(log, item, imagepath, maxwidth=None, itempath=None,
log, compare_threshold=0, ifempty=False, as_album=False, id3v23=None,
item, quality=0):
imagepath, """Embed an image into the item's media file.
maxwidth=None, """
itempath=None, # Conditions and filters.
compare_threshold=0,
ifempty=False,
as_album=False,
id3v23=None,
quality=0,
):
"""Embed an image into the item's media file."""
# Conditions.
if compare_threshold: if compare_threshold:
is_similar = check_art_similarity( if not check_art_similarity(log, item, imagepath, compare_threshold):
log, item, imagepath, compare_threshold log.info('Image not similar; skipping.')
)
if is_similar is None:
log.warning("Error while checking art similarity; skipping.")
return return
elif not is_similar:
log.info("Image not similar; skipping.")
return
if ifempty and get_art(log, item): if ifempty and get_art(log, item):
log.info("media file already contained art") log.info('media file already contained art')
return return
# Filters.
if maxwidth and not as_album: if maxwidth and not as_album:
imagepath = resize_image(log, imagepath, maxwidth, quality) imagepath = resize_image(log, imagepath, maxwidth, quality)
# Get the `Image` object from the file. # Get the `Image` object from the file.
try: try:
log.debug("embedding {0}", displayable_path(imagepath)) log.debug('embedding {0}', displayable_path(imagepath))
image = mediafile_image(imagepath, maxwidth) image = mediafile_image(imagepath, maxwidth)
except OSError as exc: except OSError as exc:
log.warning("could not read image file: {0}", exc) log.warning('could not read image file: {0}', exc)
return return
# Make sure the image kind is safe (some formats only support PNG # Make sure the image kind is safe (some formats only support PNG
# and JPEG). # and JPEG).
if image.mime_type not in ("image/jpeg", "image/png"): if image.mime_type not in ('image/jpeg', 'image/png'):
log.info("not embedding image of unsupported type: {}", image.mime_type) log.info('not embedding image of unsupported type: {}',
image.mime_type)
return return
item.try_write(path=itempath, tags={"images": [image]}, id3v23=id3v23) item.try_write(path=itempath, tags={'images': [image]}, id3v23=id3v23)
def embed_album( def embed_album(log, album, maxwidth=None, quiet=False, compare_threshold=0,
log, ifempty=False, quality=0):
album, """Embed album art into all of the album's items.
maxwidth=None, """
quiet=False,
compare_threshold=0,
ifempty=False,
quality=0,
):
"""Embed album art into all of the album's items."""
imagepath = album.artpath imagepath = album.artpath
if not imagepath: if not imagepath:
log.info("No album art present for {0}", album) log.info('No album art present for {0}', album)
return return
if not os.path.isfile(syspath(imagepath)): if not os.path.isfile(syspath(imagepath)):
log.info( log.info('Album art not found at {0} for {1}',
"Album art not found at {0} for {1}", displayable_path(imagepath), album)
displayable_path(imagepath),
album,
)
return return
if maxwidth: if maxwidth:
imagepath = resize_image(log, imagepath, maxwidth, quality) imagepath = resize_image(log, imagepath, maxwidth, quality)
log.info("Embedding album art into {0}", album) log.info('Embedding album art into {0}', album)
for item in album.items(): for item in album.items():
embed_item( embed_item(log, item, imagepath, maxwidth, None, compare_threshold,
log, ifempty, as_album=True, quality=quality)
item,
imagepath,
maxwidth,
None,
compare_threshold,
ifempty,
as_album=True,
quality=quality,
)
def resize_image(log, imagepath, maxwidth, quality): def resize_image(log, imagepath, maxwidth, quality):
"""Returns path to an image resized to maxwidth and encoded with the """Returns path to an image resized to maxwidth and encoded with the
specified quality level. specified quality level.
""" """
log.debug( log.debug('Resizing album art to {0} pixels wide and encoding at quality \
"Resizing album art to {0} pixels wide and encoding at quality \ level {1}', maxwidth, quality)
level {1}", imagepath = ArtResizer.shared.resize(maxwidth, syspath(imagepath),
maxwidth, quality=quality)
quality,
)
imagepath = ArtResizer.shared.resize(
maxwidth, syspath(imagepath), quality=quality
)
return imagepath return imagepath
def check_art_similarity( def check_art_similarity(log, item, imagepath, compare_threshold):
log,
item,
imagepath,
compare_threshold,
artresizer=None,
):
"""A boolean indicating if an image is similar to embedded item art. """A boolean indicating if an image is similar to embedded item art.
If no embedded art exists, always return `True`. If the comparison fails
for some reason, the return value is `None`.
This must only be called if `ArtResizer.shared.can_compare` is `True`.
""" """
with NamedTemporaryFile(delete=True) as f: with NamedTemporaryFile(delete=True) as f:
art = extract(log, f.name, item) art = extract(log, f.name, item)
if not art: if art:
return True is_windows = platform.system() == "Windows"
if artresizer is None: # Converting images to grayscale tends to minimize the weight
artresizer = ArtResizer.shared # of colors in the diff score. So we first convert both images
# to grayscale and then pipe them into the `compare` command.
# On Windows, ImageMagick doesn't support the magic \\?\ prefix
# on paths, so we pass `prefix=False` to `syspath`.
convert_cmd = ['convert', syspath(imagepath, prefix=False),
syspath(art, prefix=False),
'-colorspace', 'gray', 'MIFF:-']
compare_cmd = ['compare', '-metric', 'PHASH', '-', 'null:']
log.debug('comparing images with pipeline {} | {}',
convert_cmd, compare_cmd)
convert_proc = subprocess.Popen(
convert_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
close_fds=not is_windows,
)
compare_proc = subprocess.Popen(
compare_cmd,
stdin=convert_proc.stdout,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
close_fds=not is_windows,
)
return artresizer.compare(art, imagepath, compare_threshold) # Check the convert output. We're not interested in the
# standard output; that gets piped to the next stage.
convert_proc.stdout.close()
convert_stderr = convert_proc.stderr.read()
convert_proc.stderr.close()
convert_proc.wait()
if convert_proc.returncode:
log.debug(
'ImageMagick convert failed with status {}: {!r}',
convert_proc.returncode,
convert_stderr,
)
return
# Check the compare output.
stdout, stderr = compare_proc.communicate()
if compare_proc.returncode:
if compare_proc.returncode != 1:
log.debug('ImageMagick compare failed: {0}, {1}',
displayable_path(imagepath),
displayable_path(art))
return
out_str = stderr
else:
out_str = stdout
try:
phash_diff = float(out_str)
except ValueError:
log.debug('IM output is not a number: {0!r}', out_str)
return
log.debug('ImageMagick compare score: {0}', phash_diff)
return phash_diff <= compare_threshold
return True
def extract(log, outpath, item): def extract(log, outpath, item):
art = get_art(log, item) art = get_art(log, item)
outpath = bytestring_path(outpath) outpath = bytestring_path(outpath)
if not art: if not art:
log.info("No album art present in {0}, skipping.", item) log.info('No album art present in {0}, skipping.', item)
return return
# Add an extension to the filename. # Add an extension to the filename.
ext = mediafile.image_extension(art) ext = mediafile.image_extension(art)
if not ext: if not ext:
log.warning("Unknown image type in {0}.", displayable_path(item.path)) log.warning('Unknown image type in {0}.',
displayable_path(item.path))
return return
outpath += bytestring_path("." + ext) outpath += bytestring_path('.' + ext)
log.info( log.info('Extracting album art from: {0} to: {1}',
"Extracting album art from: {0} to: {1}", item, displayable_path(outpath))
item, with open(syspath(outpath), 'wb') as f:
displayable_path(outpath),
)
with open(syspath(outpath), "wb") as f:
f.write(art) f.write(art)
return outpath return outpath
@@ -214,7 +218,7 @@ def extract_first(log, outpath, items):
def clear(log, lib, query): def clear(log, lib, query):
items = lib.items(query) items = lib.items(query)
log.info("Clearing album art from {0} items", len(items)) log.info('Clearing album art from {0} items', len(items))
for item in items: for item in items:
log.debug("Clearing art for {0}", item) log.debug('Clearing art for {0}', item)
item.try_write(tags={"images": None}) item.try_write(tags={'images': None})
+62 -99
View File
@@ -14,91 +14,78 @@
"""Facilities for automatically determining files' correct metadata. """Facilities for automatically determining files' correct metadata.
""" """
from typing import Mapping
from beets import config, logging
from beets.library import Item from beets import logging
from beets import config
# Parts of external interface. # Parts of external interface.
from .hooks import ( # noqa from .hooks import ( # noqa
AlbumInfo, AlbumInfo,
AlbumMatch,
Distance,
TrackInfo, TrackInfo,
AlbumMatch,
TrackMatch, TrackMatch,
Distance,
) )
from .match import tag_item, tag_album, Proposal # noqa
from .match import Recommendation # noqa from .match import Recommendation # noqa
from .match import Proposal, current_metadata, tag_album, tag_item # noqa
# Global logger. # Global logger.
log = logging.getLogger("beets") log = logging.getLogger('beets')
# Metadata fields that are already hardcoded, or where the tag name changes. # Metadata fields that are already hardcoded, or where the tag name changes.
SPECIAL_FIELDS = { SPECIAL_FIELDS = {
"album": ( 'album': (
"va", 'va',
"releasegroup_id", 'releasegroup_id',
"artist_id", 'artist_id',
"artists_ids", 'album_id',
"album_id", 'mediums',
"mediums", 'tracks',
"tracks", 'year',
"year", 'month',
"month", 'day',
"day", 'artist',
"artist", 'artist_credit',
"artists", 'artist_sort',
"artist_credit", 'data_url'
"artists_credit",
"artist_sort",
"artists_sort",
"data_url",
),
"track": (
"track_alt",
"artist_id",
"artists_ids",
"release_track_id",
"medium",
"index",
"medium_index",
"title",
"artist_credit",
"artists_credit",
"artist_sort",
"artists_sort",
"artist",
"artists",
"track_id",
"medium_total",
"data_url",
"length",
), ),
'track': (
'track_alt',
'artist_id',
'release_track_id',
'medium',
'index',
'medium_index',
'title',
'artist_credit',
'artist_sort',
'artist',
'track_id',
'medium_total',
'data_url',
'length'
)
} }
# Additional utilities for the main interface. # Additional utilities for the main interface.
def apply_item_metadata(item, track_info):
def apply_item_metadata(item: Item, track_info: TrackInfo): """Set an item's metadata from its matched TrackInfo object.
"""Set an item's metadata from its matched TrackInfo object.""" """
item.artist = track_info.artist item.artist = track_info.artist
item.artists = track_info.artists
item.artist_sort = track_info.artist_sort item.artist_sort = track_info.artist_sort
item.artists_sort = track_info.artists_sort
item.artist_credit = track_info.artist_credit item.artist_credit = track_info.artist_credit
item.artists_credit = track_info.artists_credit
item.title = track_info.title item.title = track_info.title
item.mb_trackid = track_info.track_id item.mb_trackid = track_info.track_id
item.mb_releasetrackid = track_info.release_track_id item.mb_releasetrackid = track_info.release_track_id
if track_info.artist_id: if track_info.artist_id:
item.mb_artistid = track_info.artist_id item.mb_artistid = track_info.artist_id
if track_info.artists_ids:
item.mb_artistids = track_info.artists_ids
for field, value in track_info.items(): for field, value in track_info.items():
# We only overwrite fields that are not already hardcoded. # We only overwrite fields that are not already hardcoded.
if field in SPECIAL_FIELDS["track"]: if field in SPECIAL_FIELDS['track']:
continue continue
if value is None: if value is None:
continue continue
@@ -108,62 +95,45 @@ def apply_item_metadata(item: Item, track_info: TrackInfo):
# and track number). Perhaps these should be emptied? # and track number). Perhaps these should be emptied?
def apply_metadata(album_info: AlbumInfo, mapping: Mapping[Item, TrackInfo]): def apply_metadata(album_info, mapping):
"""Set the items' metadata to match an AlbumInfo object using a """Set the items' metadata to match an AlbumInfo object using a
mapping from Items to TrackInfo objects. mapping from Items to TrackInfo objects.
""" """
for item, track_info in mapping.items(): for item, track_info in mapping.items():
# Artist or artist credit. # Artist or artist credit.
if config["artist_credit"]: if config['artist_credit']:
item.artist = ( item.artist = (track_info.artist_credit or
track_info.artist_credit track_info.artist or
or track_info.artist album_info.artist_credit or
or album_info.artist_credit album_info.artist)
or album_info.artist item.albumartist = (album_info.artist_credit or
) album_info.artist)
item.artists = (
track_info.artists_credit
or track_info.artists
or album_info.artists_credit
or album_info.artists
)
item.albumartist = album_info.artist_credit or album_info.artist
item.albumartists = album_info.artists_credit or album_info.artists
else: else:
item.artist = track_info.artist or album_info.artist item.artist = (track_info.artist or album_info.artist)
item.artists = track_info.artists or album_info.artists
item.albumartist = album_info.artist item.albumartist = album_info.artist
item.albumartists = album_info.artists
# Album. # Album.
item.album = album_info.album item.album = album_info.album
# Artist sort and credit names. # Artist sort and credit names.
item.artist_sort = track_info.artist_sort or album_info.artist_sort item.artist_sort = track_info.artist_sort or album_info.artist_sort
item.artists_sort = track_info.artists_sort or album_info.artists_sort item.artist_credit = (track_info.artist_credit or
item.artist_credit = ( album_info.artist_credit)
track_info.artist_credit or album_info.artist_credit
)
item.artists_credit = (
track_info.artists_credit or album_info.artists_credit
)
item.albumartist_sort = album_info.artist_sort item.albumartist_sort = album_info.artist_sort
item.albumartists_sort = album_info.artists_sort
item.albumartist_credit = album_info.artist_credit item.albumartist_credit = album_info.artist_credit
item.albumartists_credit = album_info.artists_credit
# Release date. # Release date.
for prefix in "", "original_": for prefix in '', 'original_':
if config["original_date"] and not prefix: if config['original_date'] and not prefix:
# Ignore specific release date. # Ignore specific release date.
continue continue
for suffix in "year", "month", "day": for suffix in 'year', 'month', 'day':
key = prefix + suffix key = prefix + suffix
value = getattr(album_info, key) or 0 value = getattr(album_info, key) or 0
# If we don't even have a year, apply nothing. # If we don't even have a year, apply nothing.
if suffix == "year" and not value: if suffix == 'year' and not value:
break break
# Otherwise, set the fetched value (or 0 for the month # Otherwise, set the fetched value (or 0 for the month
@@ -172,13 +142,13 @@ def apply_metadata(album_info: AlbumInfo, mapping: Mapping[Item, TrackInfo]):
# If we're using original release date for both fields, # If we're using original release date for both fields,
# also set item.year = info.original_year, etc. # also set item.year = info.original_year, etc.
if config["original_date"]: if config['original_date']:
item[suffix] = value item[suffix] = value
# Title. # Title.
item.title = track_info.title item.title = track_info.title
if config["per_disc_numbering"]: if config['per_disc_numbering']:
# We want to let the track number be zero, but if the medium index # We want to let the track number be zero, but if the medium index
# is not provided we need to fall back to the overall index. # is not provided we need to fall back to the overall index.
if track_info.medium_index is not None: if track_info.medium_index is not None:
@@ -202,14 +172,7 @@ def apply_metadata(album_info: AlbumInfo, mapping: Mapping[Item, TrackInfo]):
item.mb_artistid = track_info.artist_id item.mb_artistid = track_info.artist_id
else: else:
item.mb_artistid = album_info.artist_id item.mb_artistid = album_info.artist_id
if track_info.artists_ids:
item.mb_artistids = track_info.artists_ids
else:
item.mb_artistids = album_info.artists_ids
item.mb_albumartistid = album_info.artist_id item.mb_albumartistid = album_info.artist_id
item.mb_albumartistids = album_info.artists_ids
item.mb_releasegroupid = album_info.releasegroup_id item.mb_releasegroupid = album_info.releasegroup_id
# Compilation flag. # Compilation flag.
@@ -221,17 +184,17 @@ def apply_metadata(album_info: AlbumInfo, mapping: Mapping[Item, TrackInfo]):
# Don't overwrite fields with empty values unless the # Don't overwrite fields with empty values unless the
# field is explicitly allowed to be overwritten # field is explicitly allowed to be overwritten
for field, value in album_info.items(): for field, value in album_info.items():
if field in SPECIAL_FIELDS["album"]: if field in SPECIAL_FIELDS['album']:
continue continue
clobber = field in config["overwrite_null"]["album"].as_str_seq() clobber = field in config['overwrite_null']['album'].as_str_seq()
if value is None and not clobber: if value is None and not clobber:
continue continue
item[field] = value item[field] = value
for field, value in track_info.items(): for field, value in track_info.items():
if field in SPECIAL_FIELDS["track"]: if field in SPECIAL_FIELDS['track']:
continue continue
clobber = field in config["overwrite_null"]["track"].as_str_seq() clobber = field in config['overwrite_null']['track'].as_str_seq()
value = getattr(track_info, field) value = getattr(track_info, field)
if value is None and not clobber: if value is None and not clobber:
continue continue
+184 -242
View File
@@ -14,51 +14,40 @@
"""Glue between metadata sources and the matching logic.""" """Glue between metadata sources and the matching logic."""
from __future__ import annotations
import re
from collections import namedtuple from collections import namedtuple
from functools import total_ordering from functools import total_ordering
from typing import ( import re
Any,
Callable,
Dict,
Iterable,
Iterator,
List,
Optional,
Tuple,
TypeVar,
Union,
cast,
)
from beets import logging
from beets import plugins
from beets import config
from beets.util import as_string
from beets.autotag import mb
from jellyfish import levenshtein_distance from jellyfish import levenshtein_distance
from unidecode import unidecode from unidecode import unidecode
from beets import config, logging, plugins log = logging.getLogger('beets')
from beets.autotag import mb
from beets.library import Item
from beets.util import as_string, cached_classproperty
log = logging.getLogger("beets") # The name of the type for patterns in re changed in Python 3.7.
try:
V = TypeVar("V") Pattern = re._pattern_type
except AttributeError:
Pattern = re.Pattern
# Classes used to represent candidate options. # Classes used to represent candidate options.
class AttrDict(Dict[str, V]): class AttrDict(dict):
"""A dictionary that supports attribute ("dot") access, so `d.field` """A dictionary that supports attribute ("dot") access, so `d.field`
is equivalent to `d['field']`. is equivalent to `d['field']`.
""" """
def __getattr__(self, attr: str) -> V: def __getattr__(self, attr):
if attr in self: if attr in self:
return self[attr] return self.get(attr)
else: else:
raise AttributeError raise AttributeError
def __setattr__(self, key: str, value: V): def __setattr__(self, key, value):
self.__setitem__(key, value) self.__setitem__(key, value)
def __hash__(self): def __hash__(self):
@@ -79,73 +68,32 @@ class AlbumInfo(AttrDict):
The others are optional and may be None. The others are optional and may be None.
""" """
# TYPING: are all of these correct? I've assumed optional strings def __init__(self, tracks, album=None, album_id=None, artist=None,
def __init__( artist_id=None, asin=None, albumtype=None, va=False,
self, year=None, month=None, day=None, label=None, mediums=None,
tracks: List[TrackInfo], artist_sort=None, releasegroup_id=None, catalognum=None,
album: Optional[str] = None, script=None, language=None, country=None, style=None,
album_id: Optional[str] = None, genre=None, albumstatus=None, media=None, albumdisambig=None,
artist: Optional[str] = None, releasegroupdisambig=None, artist_credit=None,
artist_id: Optional[str] = None, original_year=None, original_month=None,
artists: Optional[List[str]] = None, original_day=None, data_source=None, data_url=None,
artists_ids: Optional[List[str]] = None, discogs_albumid=None, discogs_labelid=None,
asin: Optional[str] = None, discogs_artistid=None, **kwargs):
albumtype: Optional[str] = None,
albumtypes: Optional[List[str]] = None,
va: bool = False,
year: Optional[int] = None,
month: Optional[int] = None,
day: Optional[int] = None,
label: Optional[str] = None,
barcode: Optional[str] = None,
mediums: Optional[int] = None,
artist_sort: Optional[str] = None,
artists_sort: Optional[List[str]] = None,
releasegroup_id: Optional[str] = None,
release_group_title: Optional[str] = None,
catalognum: Optional[str] = None,
script: Optional[str] = None,
language: Optional[str] = None,
country: Optional[str] = None,
style: Optional[str] = None,
genre: Optional[str] = None,
albumstatus: Optional[str] = None,
media: Optional[str] = None,
albumdisambig: Optional[str] = None,
releasegroupdisambig: Optional[str] = None,
artist_credit: Optional[str] = None,
artists_credit: Optional[List[str]] = None,
original_year: Optional[int] = None,
original_month: Optional[int] = None,
original_day: Optional[int] = None,
data_source: Optional[str] = None,
data_url: Optional[str] = None,
discogs_albumid: Optional[str] = None,
discogs_labelid: Optional[str] = None,
discogs_artistid: Optional[str] = None,
**kwargs,
):
self.album = album self.album = album
self.album_id = album_id self.album_id = album_id
self.artist = artist self.artist = artist
self.artist_id = artist_id self.artist_id = artist_id
self.artists = artists or []
self.artists_ids = artists_ids or []
self.tracks = tracks self.tracks = tracks
self.asin = asin self.asin = asin
self.albumtype = albumtype self.albumtype = albumtype
self.albumtypes = albumtypes or []
self.va = va self.va = va
self.year = year self.year = year
self.month = month self.month = month
self.day = day self.day = day
self.label = label self.label = label
self.barcode = barcode
self.mediums = mediums self.mediums = mediums
self.artist_sort = artist_sort self.artist_sort = artist_sort
self.artists_sort = artists_sort or []
self.releasegroup_id = releasegroup_id self.releasegroup_id = releasegroup_id
self.release_group_title = release_group_title
self.catalognum = catalognum self.catalognum = catalognum
self.script = script self.script = script
self.language = language self.language = language
@@ -157,7 +105,6 @@ class AlbumInfo(AttrDict):
self.albumdisambig = albumdisambig self.albumdisambig = albumdisambig
self.releasegroupdisambig = releasegroupdisambig self.releasegroupdisambig = releasegroupdisambig
self.artist_credit = artist_credit self.artist_credit = artist_credit
self.artists_credit = artists_credit or []
self.original_year = original_year self.original_year = original_year
self.original_month = original_month self.original_month = original_month
self.original_day = original_day self.original_day = original_day
@@ -168,7 +115,27 @@ class AlbumInfo(AttrDict):
self.discogs_artistid = discogs_artistid self.discogs_artistid = discogs_artistid
self.update(kwargs) self.update(kwargs)
def copy(self) -> AlbumInfo: # Work around a bug in python-musicbrainz-ngs that causes some
# strings to be bytes rather than Unicode.
# https://github.com/alastair/python-musicbrainz-ngs/issues/85
def decode(self, codec='utf-8'):
"""Ensure that all string attributes on this object, and the
constituent `TrackInfo` objects, are decoded to Unicode.
"""
for fld in ['album', 'artist', 'albumtype', 'label', 'artist_sort',
'catalognum', 'script', 'language', 'country', 'style',
'genre', 'albumstatus', 'albumdisambig',
'releasegroupdisambig', 'artist_credit',
'media', 'discogs_albumid', 'discogs_labelid',
'discogs_artistid']:
value = getattr(self, fld)
if isinstance(value, bytes):
setattr(self, fld, value.decode(codec, 'ignore'))
for track in self.tracks:
track.decode(codec)
def copy(self):
dupe = AlbumInfo([]) dupe = AlbumInfo([])
dupe.update(self) dupe.update(self)
dupe.tracks = [track.copy() for track in self.tracks] dupe.tracks = [track.copy() for track in self.tracks]
@@ -187,50 +154,20 @@ class TrackInfo(AttrDict):
are all 1-based. are all 1-based.
""" """
# TYPING: are all of these correct? I've assumed optional strings def __init__(self, title=None, track_id=None, release_track_id=None,
def __init__( artist=None, artist_id=None, length=None, index=None,
self, medium=None, medium_index=None, medium_total=None,
title: Optional[str] = None, artist_sort=None, disctitle=None, artist_credit=None,
track_id: Optional[str] = None, data_source=None, data_url=None, media=None, lyricist=None,
release_track_id: Optional[str] = None, composer=None, composer_sort=None, arranger=None,
artist: Optional[str] = None, track_alt=None, work=None, mb_workid=None,
artist_id: Optional[str] = None, work_disambig=None, bpm=None, initial_key=None, genre=None,
artists: Optional[List[str]] = None, **kwargs):
artists_ids: Optional[List[str]] = None,
length: Optional[float] = None,
index: Optional[int] = None,
medium: Optional[int] = None,
medium_index: Optional[int] = None,
medium_total: Optional[int] = None,
artist_sort: Optional[str] = None,
artists_sort: Optional[List[str]] = None,
disctitle: Optional[str] = None,
artist_credit: Optional[str] = None,
artists_credit: Optional[List[str]] = None,
data_source: Optional[str] = None,
data_url: Optional[str] = None,
media: Optional[str] = None,
lyricist: Optional[str] = None,
composer: Optional[str] = None,
composer_sort: Optional[str] = None,
arranger: Optional[str] = None,
track_alt: Optional[str] = None,
work: Optional[str] = None,
mb_workid: Optional[str] = None,
work_disambig: Optional[str] = None,
bpm: Optional[str] = None,
initial_key: Optional[str] = None,
genre: Optional[str] = None,
album: Optional[str] = None,
**kwargs,
):
self.title = title self.title = title
self.track_id = track_id self.track_id = track_id
self.release_track_id = release_track_id self.release_track_id = release_track_id
self.artist = artist self.artist = artist
self.artist_id = artist_id self.artist_id = artist_id
self.artists = artists or []
self.artists_ids = artists_ids or []
self.length = length self.length = length
self.index = index self.index = index
self.media = media self.media = media
@@ -238,10 +175,8 @@ class TrackInfo(AttrDict):
self.medium_index = medium_index self.medium_index = medium_index
self.medium_total = medium_total self.medium_total = medium_total
self.artist_sort = artist_sort self.artist_sort = artist_sort
self.artists_sort = artists_sort or []
self.disctitle = disctitle self.disctitle = disctitle
self.artist_credit = artist_credit self.artist_credit = artist_credit
self.artists_credit = artists_credit or []
self.data_source = data_source self.data_source = data_source
self.data_url = data_url self.data_url = data_url
self.lyricist = lyricist self.lyricist = lyricist
@@ -255,10 +190,20 @@ class TrackInfo(AttrDict):
self.bpm = bpm self.bpm = bpm
self.initial_key = initial_key self.initial_key = initial_key
self.genre = genre self.genre = genre
self.album = album
self.update(kwargs) self.update(kwargs)
def copy(self) -> TrackInfo: # As above, work around a bug in python-musicbrainz-ngs.
def decode(self, codec='utf-8'):
"""Ensure that all string attributes on this object are decoded
to Unicode.
"""
for fld in ['title', 'artist', 'medium', 'artist_sort', 'disctitle',
'artist_credit', 'media']:
value = getattr(self, fld)
if isinstance(value, bytes):
setattr(self, fld, value.decode(codec, 'ignore'))
def copy(self):
dupe = TrackInfo() dupe = TrackInfo()
dupe.update(self) dupe.update(self)
return dupe return dupe
@@ -268,23 +213,23 @@ class TrackInfo(AttrDict):
# Parameters for string distance function. # Parameters for string distance function.
# Words that can be moved to the end of a string using a comma. # Words that can be moved to the end of a string using a comma.
SD_END_WORDS = ["the", "a", "an"] SD_END_WORDS = ['the', 'a', 'an']
# Reduced weights for certain portions of the string. # Reduced weights for certain portions of the string.
SD_PATTERNS = [ SD_PATTERNS = [
(r"^the ", 0.1), (r'^the ', 0.1),
(r"[\[\(]?(ep|single)[\]\)]?", 0.0), (r'[\[\(]?(ep|single)[\]\)]?', 0.0),
(r"[\[\(]?(featuring|feat|ft)[\. :].+", 0.1), (r'[\[\(]?(featuring|feat|ft)[\. :].+', 0.1),
(r"\(.*?\)", 0.3), (r'\(.*?\)', 0.3),
(r"\[.*?\]", 0.3), (r'\[.*?\]', 0.3),
(r"(, )?(pt\.|part) .+", 0.2), (r'(, )?(pt\.|part) .+', 0.2),
] ]
# Replacements to use before testing distance. # Replacements to use before testing distance.
SD_REPLACE = [ SD_REPLACE = [
(r"&", "and"), (r'&', 'and'),
] ]
def _string_dist_basic(str1: str, str2: str) -> float: def _string_dist_basic(str1, str2):
"""Basic edit distance between two strings, ignoring """Basic edit distance between two strings, ignoring
non-alphanumeric characters and case. Comparisons are based on a non-alphanumeric characters and case. Comparisons are based on a
transliteration/lowering to ASCII characters. Normalized by string transliteration/lowering to ASCII characters. Normalized by string
@@ -294,14 +239,14 @@ def _string_dist_basic(str1: str, str2: str) -> float:
assert isinstance(str2, str) assert isinstance(str2, str)
str1 = as_string(unidecode(str1)) str1 = as_string(unidecode(str1))
str2 = as_string(unidecode(str2)) str2 = as_string(unidecode(str2))
str1 = re.sub(r"[^a-z0-9]", "", str1.lower()) str1 = re.sub(r'[^a-z0-9]', '', str1.lower())
str2 = re.sub(r"[^a-z0-9]", "", str2.lower()) str2 = re.sub(r'[^a-z0-9]', '', str2.lower())
if not str1 and not str2: if not str1 and not str2:
return 0.0 return 0.0
return levenshtein_distance(str1, str2) / float(max(len(str1), len(str2))) return levenshtein_distance(str1, str2) / float(max(len(str1), len(str2)))
def string_dist(str1: Optional[str], str2: Optional[str]) -> float: def string_dist(str1, str2):
"""Gives an "intuitive" edit distance between two strings. This is """Gives an "intuitive" edit distance between two strings. This is
an edit distance, normalized by the string length, with a number of an edit distance, normalized by the string length, with a number of
tweaks that reflect intuition about text. tweaks that reflect intuition about text.
@@ -318,10 +263,10 @@ def string_dist(str1: Optional[str], str2: Optional[str]) -> float:
# example, "the something" should be considered equal to # example, "the something" should be considered equal to
# "something, the". # "something, the".
for word in SD_END_WORDS: for word in SD_END_WORDS:
if str1.endswith(", %s" % word): if str1.endswith(', %s' % word):
str1 = "{} {}".format(word, str1[: -len(word) - 2]) str1 = '{} {}'.format(word, str1[:-len(word) - 2])
if str2.endswith(", %s" % word): if str2.endswith(', %s' % word):
str2 = "{} {}".format(word, str2[: -len(word) - 2]) str2 = '{} {}'.format(word, str2[:-len(word) - 2])
# Perform a couple of basic normalizing substitutions. # Perform a couple of basic normalizing substitutions.
for pat, repl in SD_REPLACE: for pat, repl in SD_REPLACE:
@@ -336,8 +281,8 @@ def string_dist(str1: Optional[str], str2: Optional[str]) -> float:
penalty = 0.0 penalty = 0.0
for pat, weight in SD_PATTERNS: for pat, weight in SD_PATTERNS:
# Get strings that drop the pattern. # Get strings that drop the pattern.
case_str1 = re.sub(pat, "", str1) case_str1 = re.sub(pat, '', str1)
case_str2 = re.sub(pat, "", str2) case_str2 = re.sub(pat, '', str2)
if case_str1 != str1 or case_str2 != str2: if case_str1 != str1 or case_str2 != str2:
# If the pattern was present (i.e., it is deleted in the # If the pattern was present (i.e., it is deleted in the
@@ -359,6 +304,23 @@ def string_dist(str1: Optional[str], str2: Optional[str]) -> float:
return base_dist + penalty return base_dist + penalty
class LazyClassProperty:
"""A decorator implementing a read-only property that is *lazy* in
the sense that the getter is only invoked once. Subsequent accesses
through *any* instance use the cached result.
"""
def __init__(self, getter):
self.getter = getter
self.computed = False
def __get__(self, obj, owner):
if not self.computed:
self.value = self.getter(owner)
self.computed = True
return self.value
@total_ordering @total_ordering
class Distance: class Distance:
"""Keeps track of multiple distance penalties. Provides a single """Keeps track of multiple distance penalties. Provides a single
@@ -368,12 +330,12 @@ class Distance:
def __init__(self): def __init__(self):
self._penalties = {} self._penalties = {}
self.tracks: Dict[TrackInfo, Distance] = {}
@cached_classproperty @LazyClassProperty
def _weights(cls) -> Dict[str, float]: # noqa: N805 def _weights(cls): # noqa: N805
"""A dictionary from keys to floating-point weights.""" """A dictionary from keys to floating-point weights.
weights_view = config["match"]["distance_weights"] """
weights_view = config['match']['distance_weights']
weights = {} weights = {}
for key in weights_view.keys(): for key in weights_view.keys():
weights[key] = weights_view[key].as_number() weights[key] = weights_view[key].as_number()
@@ -382,7 +344,7 @@ class Distance:
# Access the components and their aggregates. # Access the components and their aggregates.
@property @property
def distance(self) -> float: def distance(self):
"""Return a weighted and normalized distance across all """Return a weighted and normalized distance across all
penalties. penalties.
""" """
@@ -392,22 +354,24 @@ class Distance:
return 0.0 return 0.0
@property @property
def max_distance(self) -> float: def max_distance(self):
"""Return the maximum distance penalty (normalization factor).""" """Return the maximum distance penalty (normalization factor).
"""
dist_max = 0.0 dist_max = 0.0
for key, penalty in self._penalties.items(): for key, penalty in self._penalties.items():
dist_max += len(penalty) * self._weights[key] dist_max += len(penalty) * self._weights[key]
return dist_max return dist_max
@property @property
def raw_distance(self) -> float: def raw_distance(self):
"""Return the raw (denormalized) distance.""" """Return the raw (denormalized) distance.
"""
dist_raw = 0.0 dist_raw = 0.0
for key, penalty in self._penalties.items(): for key, penalty in self._penalties.items():
dist_raw += sum(penalty) * self._weights[key] dist_raw += sum(penalty) * self._weights[key]
return dist_raw return dist_raw
def items(self) -> List[Tuple[str, float]]: def items(self):
"""Return a list of (key, dist) pairs, with `dist` being the """Return a list of (key, dist) pairs, with `dist` being the
weighted distance, sorted from highest to lowest. Does not weighted distance, sorted from highest to lowest. Does not
include penalties with a zero value. include penalties with a zero value.
@@ -421,88 +385,87 @@ class Distance:
# ascending order (for keys, when the penalty is equal) and # ascending order (for keys, when the penalty is equal) and
# still get the items with the biggest distance first. # still get the items with the biggest distance first.
return sorted( return sorted(
list_, key=lambda key_and_dist: (-key_and_dist[1], key_and_dist[0]) list_,
key=lambda key_and_dist: (-key_and_dist[1], key_and_dist[0])
) )
def __hash__(self) -> int: def __hash__(self):
return id(self) return id(self)
def __eq__(self, other) -> bool: def __eq__(self, other):
return self.distance == other return self.distance == other
# Behave like a float. # Behave like a float.
def __lt__(self, other) -> bool: def __lt__(self, other):
return self.distance < other return self.distance < other
def __float__(self) -> float: def __float__(self):
return self.distance return self.distance
def __sub__(self, other) -> float: def __sub__(self, other):
return self.distance - other return self.distance - other
def __rsub__(self, other) -> float: def __rsub__(self, other):
return other - self.distance return other - self.distance
def __str__(self) -> str: def __str__(self):
return f"{self.distance:.2f}" return f"{self.distance:.2f}"
# Behave like a dict. # Behave like a dict.
def __getitem__(self, key) -> float: def __getitem__(self, key):
"""Returns the weighted distance for a named penalty.""" """Returns the weighted distance for a named penalty.
"""
dist = sum(self._penalties[key]) * self._weights[key] dist = sum(self._penalties[key]) * self._weights[key]
dist_max = self.max_distance dist_max = self.max_distance
if dist_max: if dist_max:
return dist / dist_max return dist / dist_max
return 0.0 return 0.0
def __iter__(self) -> Iterator[Tuple[str, float]]: def __iter__(self):
return iter(self.items()) return iter(self.items())
def __len__(self) -> int: def __len__(self):
return len(self.items()) return len(self.items())
def keys(self) -> List[str]: def keys(self):
return [key for key, _ in self.items()] return [key for key, _ in self.items()]
def update(self, dist: "Distance"): def update(self, dist):
"""Adds all the distance penalties from `dist`.""" """Adds all the distance penalties from `dist`.
"""
if not isinstance(dist, Distance): if not isinstance(dist, Distance):
raise ValueError( raise ValueError(
"`dist` must be a Distance object, not {}".format(type(dist)) '`dist` must be a Distance object, not {}'.format(type(dist))
) )
for key, penalties in dist._penalties.items(): for key, penalties in dist._penalties.items():
self._penalties.setdefault(key, []).extend(penalties) self._penalties.setdefault(key, []).extend(penalties)
# Adding components. # Adding components.
def _eq(self, value1: Union[re.Pattern[str], Any], value2: Any) -> bool: def _eq(self, value1, value2):
"""Returns True if `value1` is equal to `value2`. `value1` may """Returns True if `value1` is equal to `value2`. `value1` may
be a compiled regular expression, in which case it will be be a compiled regular expression, in which case it will be
matched against `value2`. matched against `value2`.
""" """
if isinstance(value1, re.Pattern): if isinstance(value1, Pattern):
value2 = cast(str, value2)
return bool(value1.match(value2)) return bool(value1.match(value2))
return value1 == value2 return value1 == value2
def add(self, key: str, dist: float): def add(self, key, dist):
"""Adds a distance penalty. `key` must correspond with a """Adds a distance penalty. `key` must correspond with a
configured weight setting. `dist` must be a float between 0.0 configured weight setting. `dist` must be a float between 0.0
and 1.0, and will be added to any existing distance penalties and 1.0, and will be added to any existing distance penalties
for the same key. for the same key.
""" """
if not 0.0 <= dist <= 1.0: if not 0.0 <= dist <= 1.0:
raise ValueError(f"`dist` must be between 0.0 and 1.0, not {dist}") raise ValueError(
f'`dist` must be between 0.0 and 1.0, not {dist}'
)
self._penalties.setdefault(key, []).append(dist) self._penalties.setdefault(key, []).append(dist)
def add_equality( def add_equality(self, key, value, options):
self,
key: str,
value: Any,
options: Union[List[Any], Tuple[Any, ...], Any],
):
"""Adds a distance penalty of 1.0 if `value` doesn't match any """Adds a distance penalty of 1.0 if `value` doesn't match any
of the values in `options`. If an option is a compiled regular of the values in `options`. If an option is a compiled regular
expression, it will be considered equal if it matches against expression, it will be considered equal if it matches against
@@ -518,7 +481,7 @@ class Distance:
dist = 1.0 dist = 1.0
self.add(key, dist) self.add(key, dist)
def add_expr(self, key: str, expr: bool): def add_expr(self, key, expr):
"""Adds a distance penalty of 1.0 if `expr` evaluates to True, """Adds a distance penalty of 1.0 if `expr` evaluates to True,
or 0.0. or 0.0.
""" """
@@ -527,7 +490,7 @@ class Distance:
else: else:
self.add(key, 0.0) self.add(key, 0.0)
def add_number(self, key: str, number1: int, number2: int): def add_number(self, key, number1, number2):
"""Adds a distance penalty of 1.0 for each number of difference """Adds a distance penalty of 1.0 for each number of difference
between `number1` and `number2`, or 0.0 when there is no between `number1` and `number2`, or 0.0 when there is no
difference. Use this when there is no upper limit on the difference. Use this when there is no upper limit on the
@@ -540,12 +503,7 @@ class Distance:
else: else:
self.add(key, 0.0) self.add(key, 0.0)
def add_priority( def add_priority(self, key, value, options):
self,
key: str,
value: Any,
options: Union[List[Any], Tuple[Any, ...], Any],
):
"""Adds a distance penalty that corresponds to the position at """Adds a distance penalty that corresponds to the position at
which `value` appears in `options`. A distance penalty of 0.0 which `value` appears in `options`. A distance penalty of 0.0
for the first option, or 1.0 if there is no matching option. If for the first option, or 1.0 if there is no matching option. If
@@ -563,12 +521,7 @@ class Distance:
dist = 1.0 dist = 1.0
self.add(key, dist) self.add(key, dist)
def add_ratio( def add_ratio(self, key, number1, number2):
self,
key: str,
number1: Union[int, float],
number2: Union[int, float],
):
"""Adds a distance penalty for `number1` as a ratio of `number2`. """Adds a distance penalty for `number1` as a ratio of `number2`.
`number1` is bound at 0 and `number2`. `number1` is bound at 0 and `number2`.
""" """
@@ -579,7 +532,7 @@ class Distance:
dist = 0.0 dist = 0.0
self.add(key, dist) self.add(key, dist)
def add_string(self, key: str, str1: Optional[str], str2: Optional[str]): def add_string(self, key, str1, str2):
"""Adds a distance penalty based on the edit distance between """Adds a distance penalty based on the edit distance between
`str1` and `str2`. `str1` and `str2`.
""" """
@@ -589,82 +542,64 @@ class Distance:
# Structures that compose all the information for a candidate match. # Structures that compose all the information for a candidate match.
AlbumMatch = namedtuple( AlbumMatch = namedtuple('AlbumMatch', ['distance', 'info', 'mapping',
"AlbumMatch", ["distance", "info", "mapping", "extra_items", "extra_tracks"] 'extra_items', 'extra_tracks'])
)
TrackMatch = namedtuple("TrackMatch", ["distance", "info"]) TrackMatch = namedtuple('TrackMatch', ['distance', 'info'])
# Aggregation of sources. # Aggregation of sources.
def album_for_mbid(release_id):
def album_for_mbid(release_id: str) -> Optional[AlbumInfo]:
"""Get an AlbumInfo object for a MusicBrainz release ID. Return None """Get an AlbumInfo object for a MusicBrainz release ID. Return None
if the ID is not found. if the ID is not found.
""" """
try: try:
album = mb.album_for_id(release_id) album = mb.album_for_id(release_id)
if album: if album:
plugins.send("albuminfo_received", info=album) plugins.send('albuminfo_received', info=album)
return album return album
except mb.MusicBrainzAPIError as exc: except mb.MusicBrainzAPIError as exc:
exc.log(log) exc.log(log)
return None
def track_for_mbid(recording_id: str) -> Optional[TrackInfo]: def track_for_mbid(recording_id):
"""Get a TrackInfo object for a MusicBrainz recording ID. Return None """Get a TrackInfo object for a MusicBrainz recording ID. Return None
if the ID is not found. if the ID is not found.
""" """
try: try:
track = mb.track_for_id(recording_id) track = mb.track_for_id(recording_id)
if track: if track:
plugins.send("trackinfo_received", info=track) plugins.send('trackinfo_received', info=track)
return track return track
except mb.MusicBrainzAPIError as exc: except mb.MusicBrainzAPIError as exc:
exc.log(log) exc.log(log)
return None
def albums_for_id(album_id: str) -> Iterable[AlbumInfo]: def albums_for_id(album_id):
"""Get a list of albums for an ID.""" """Get a list of albums for an ID."""
a = album_for_mbid(album_id) a = album_for_mbid(album_id)
if a: if a:
yield a yield a
for a in plugins.album_for_id(album_id): for a in plugins.album_for_id(album_id):
if a: if a:
plugins.send("albuminfo_received", info=a) plugins.send('albuminfo_received', info=a)
yield a yield a
def tracks_for_id(track_id: str) -> Iterable[TrackInfo]: def tracks_for_id(track_id):
"""Get a list of tracks for an ID.""" """Get a list of tracks for an ID."""
t = track_for_mbid(track_id) t = track_for_mbid(track_id)
if t: if t:
yield t yield t
for t in plugins.track_for_id(track_id): for t in plugins.track_for_id(track_id):
if t: if t:
plugins.send("trackinfo_received", info=t) plugins.send('trackinfo_received', info=t)
yield t yield t
def invoke_mb(call_func: Callable, *args): @plugins.notify_info_yielded('albuminfo_received')
try: def album_candidates(items, artist, album, va_likely, extra_tags):
return call_func(*args)
except mb.MusicBrainzAPIError as exc:
exc.log(log)
return ()
@plugins.notify_info_yielded("albuminfo_received")
def album_candidates(
items: List[Item],
artist: str,
album: str,
va_likely: bool,
extra_tags: Dict,
) -> Iterable[Tuple]:
"""Search for album matches. ``items`` is a list of Item objects """Search for album matches. ``items`` is a list of Item objects
that make up the album. ``artist`` and ``album`` are the respective that make up the album. ``artist`` and ``album`` are the respective
names (strings), which may be derived from the item list or may be names (strings), which may be derived from the item list or may be
@@ -674,33 +609,40 @@ def album_candidates(
constrain the search. constrain the search.
""" """
if config["musicbrainz"]["enabled"]: # Base candidates if we have album and artist to match.
# Base candidates if we have album and artist to match. if artist and album:
if artist and album: try:
yield from invoke_mb( yield from mb.match_album(artist, album, len(items),
mb.match_album, artist, album, len(items), extra_tags extra_tags)
) except mb.MusicBrainzAPIError as exc:
exc.log(log)
# Also add VA matches from MusicBrainz where appropriate. # Also add VA matches from MusicBrainz where appropriate.
if va_likely and album: if va_likely and album:
yield from invoke_mb( try:
mb.match_album, None, album, len(items), extra_tags yield from mb.match_album(None, album, len(items),
) extra_tags)
except mb.MusicBrainzAPIError as exc:
exc.log(log)
# Candidates from plugins. # Candidates from plugins.
yield from plugins.candidates(items, artist, album, va_likely, extra_tags) yield from plugins.candidates(items, artist, album, va_likely,
extra_tags)
@plugins.notify_info_yielded("trackinfo_received") @plugins.notify_info_yielded('trackinfo_received')
def item_candidates(item: Item, artist: str, title: str) -> Iterable[Tuple]: def item_candidates(item, artist, title):
"""Search for item matches. ``item`` is the Item to be matched. """Search for item matches. ``item`` is the Item to be matched.
``artist`` and ``title`` are strings and either reflect the item or ``artist`` and ``title`` are strings and either reflect the item or
are specified by the user. are specified by the user.
""" """
# MusicBrainz candidates. # MusicBrainz candidates.
if config["musicbrainz"]["enabled"] and artist and title: if artist and title:
yield from invoke_mb(mb.match_track, artist, title) try:
yield from mb.match_track(artist, title)
except mb.MusicBrainzAPIError as exc:
exc.log(log)
# Plugin candidates. # Plugin candidates.
yield from plugins.item_candidates(item, artist, title) yield from plugins.item_candidates(item, artist, title)
+149 -245
View File
@@ -19,53 +19,32 @@ releases and tracks.
import datetime import datetime
import re import re
from collections import namedtuple
from typing import (
Any,
Dict,
Iterable,
List,
Optional,
Sequence,
Tuple,
TypeVar,
Union,
cast,
)
from munkres import Munkres from munkres import Munkres
from collections import namedtuple
from beets import config, logging, plugins from beets import logging
from beets.autotag import ( from beets import plugins
AlbumInfo, from beets import config
AlbumMatch,
Distance,
TrackInfo,
TrackMatch,
hooks,
)
from beets.library import Item
from beets.util import plurality from beets.util import plurality
from beets.autotag import hooks
from beets.util.enumeration import OrderedEnum from beets.util.enumeration import OrderedEnum
# Artist signals that indicate "various artists". These are used at the # Artist signals that indicate "various artists". These are used at the
# album level to determine whether a given release is likely a VA # album level to determine whether a given release is likely a VA
# release and also on the track level to to remove the penalty for # release and also on the track level to to remove the penalty for
# differing artists. # differing artists.
VA_ARTISTS = ("", "various artists", "various", "va", "unknown") VA_ARTISTS = ('', 'various artists', 'various', 'va', 'unknown')
# Global logger. # Global logger.
log = logging.getLogger("beets") log = logging.getLogger('beets')
# Recommendation enumeration. # Recommendation enumeration.
class Recommendation(OrderedEnum): class Recommendation(OrderedEnum):
"""Indicates a qualitative suggestion to the user about what should """Indicates a qualitative suggestion to the user about what should
be done with a given match. be done with a given match.
""" """
none = 0 none = 0
low = 1 low = 1
medium = 2 medium = 2
@@ -76,15 +55,12 @@ class Recommendation(OrderedEnum):
# consists of a list of possible candidates (i.e., AlbumInfo or TrackInfo # consists of a list of possible candidates (i.e., AlbumInfo or TrackInfo
# objects) and a recommendation value. # objects) and a recommendation value.
Proposal = namedtuple("Proposal", ("candidates", "recommendation")) Proposal = namedtuple('Proposal', ('candidates', 'recommendation'))
# Primary matching functionality. # Primary matching functionality.
def current_metadata(items):
def current_metadata(
items: Iterable[Item],
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
"""Extract the likely current metadata for an album given a list of its """Extract the likely current metadata for an album given a list of its
items. Return two dictionaries: items. Return two dictionaries:
- The most common value for each field. - The most common value for each field.
@@ -94,36 +70,22 @@ def current_metadata(
likelies = {} likelies = {}
consensus = {} consensus = {}
fields = [ fields = ['artist', 'album', 'albumartist', 'year', 'disctotal',
"artist", 'mb_albumid', 'label', 'catalognum', 'country', 'media',
"album", 'albumdisambig']
"albumartist",
"year",
"disctotal",
"mb_albumid",
"label",
"barcode",
"catalognum",
"country",
"media",
"albumdisambig",
]
for field in fields: for field in fields:
values = [item[field] for item in items if item] values = [item[field] for item in items if item]
likelies[field], freq = plurality(values) likelies[field], freq = plurality(values)
consensus[field] = freq == len(values) consensus[field] = (freq == len(values))
# If there's an album artist consensus, use this for the artist. # If there's an album artist consensus, use this for the artist.
if consensus["albumartist"] and likelies["albumartist"]: if consensus['albumartist'] and likelies['albumartist']:
likelies["artist"] = likelies["albumartist"] likelies['artist'] = likelies['albumartist']
return likelies, consensus return likelies, consensus
def assign_items( def assign_items(items, tracks):
items: Sequence[Item],
tracks: Sequence[TrackInfo],
) -> Tuple[Dict[Item, TrackInfo], List[Item], List[TrackInfo]]:
"""Given a list of Items and a list of TrackInfo objects, find the """Given a list of Items and a list of TrackInfo objects, find the
best mapping between them. Returns a mapping from Items to TrackInfo best mapping between them. Returns a mapping from Items to TrackInfo
objects, a set of extra Items, and a set of extra TrackInfo objects, a set of extra Items, and a set of extra TrackInfo
@@ -131,17 +93,17 @@ def assign_items(
of objects of the two types. of objects of the two types.
""" """
# Construct the cost matrix. # Construct the cost matrix.
costs: List[List[Distance]] = [] costs = []
for item in items: for item in items:
row = [] row = []
for track in tracks: for i, track in enumerate(tracks):
row.append(track_distance(item, track)) row.append(track_distance(item, track))
costs.append(row) costs.append(row)
# Find a minimum-cost bipartite matching. # Find a minimum-cost bipartite matching.
log.debug("Computing track assignment...") log.debug('Computing track assignment...')
matching = Munkres().compute(costs) matching = Munkres().compute(costs)
log.debug("...done.") log.debug('...done.')
# Produce the output matching. # Produce the output matching.
mapping = {items[i]: tracks[j] for (i, j) in matching} mapping = {items[i]: tracks[j] for (i, j) in matching}
@@ -152,18 +114,14 @@ def assign_items(
return mapping, extra_items, extra_tracks return mapping, extra_items, extra_tracks
def track_index_changed(item: Item, track_info: TrackInfo) -> bool: def track_index_changed(item, track_info):
"""Returns True if the item and track info index is different. Tolerates """Returns True if the item and track info index is different. Tolerates
per disc and per release numbering. per disc and per release numbering.
""" """
return item.track not in (track_info.medium_index, track_info.index) return item.track not in (track_info.medium_index, track_info.index)
def track_distance( def track_distance(item, track_info, incl_artist=False):
item: Item,
track_info: TrackInfo,
incl_artist: bool = False,
) -> Distance:
"""Determines the significance of a track metadata change. Returns a """Determines the significance of a track metadata change. Returns a
Distance object. `incl_artist` indicates that a distance component should Distance object. `incl_artist` indicates that a distance component should
be included for the track artist (i.e., for various-artist releases). be included for the track artist (i.e., for various-artist releases).
@@ -172,37 +130,26 @@ def track_distance(
# Length. # Length.
if track_info.length: if track_info.length:
item_length = cast(float, item.length) diff = abs(item.length - track_info.length) - \
track_length_grace = cast( config['match']['track_length_grace'].as_number()
Union[float, int], dist.add_ratio('track_length', diff,
config["match"]["track_length_grace"].as_number(), config['match']['track_length_max'].as_number())
)
track_length_max = cast(
Union[float, int],
config["match"]["track_length_max"].as_number(),
)
diff = abs(item_length - track_info.length) - track_length_grace
dist.add_ratio("track_length", diff, track_length_max)
# Title. # Title.
dist.add_string("track_title", item.title, track_info.title) dist.add_string('track_title', item.title, track_info.title)
# Artist. Only check if there is actually an artist in the track data. # Artist. Only check if there is actually an artist in the track data.
if ( if incl_artist and track_info.artist and \
incl_artist item.artist.lower() not in VA_ARTISTS:
and track_info.artist dist.add_string('track_artist', item.artist, track_info.artist)
and item.artist.lower() not in VA_ARTISTS
):
dist.add_string("track_artist", item.artist, track_info.artist)
# Track index. # Track index.
if track_info.index and item.track: if track_info.index and item.track:
dist.add_expr("track_index", track_index_changed(item, track_info)) dist.add_expr('track_index', track_index_changed(item, track_info))
# Track ID. # Track ID.
if item.mb_trackid: if item.mb_trackid:
dist.add_expr("track_id", item.mb_trackid != track_info.track_id) dist.add_expr('track_id', item.mb_trackid != track_info.track_id)
# Plugins. # Plugins.
dist.update(plugins.track_distance(item, track_info)) dist.update(plugins.track_distance(item, track_info))
@@ -210,11 +157,7 @@ def track_distance(
return dist return dist
def distance( def distance(items, album_info, mapping):
items: Sequence[Item],
album_info: AlbumInfo,
mapping: Dict[Item, TrackInfo],
) -> Distance:
"""Determines how "significant" an album metadata change would be. """Determines how "significant" an album metadata change would be.
Returns a Distance object. `album_info` is an AlbumInfo object Returns a Distance object. `album_info` is an AlbumInfo object
reflecting the album to be compared. `items` is a sequence of all reflecting the album to be compared. `items` is a sequence of all
@@ -229,96 +172,90 @@ def distance(
# Artist, if not various. # Artist, if not various.
if not album_info.va: if not album_info.va:
dist.add_string("artist", likelies["artist"], album_info.artist) dist.add_string('artist', likelies['artist'], album_info.artist)
# Album. # Album.
dist.add_string("album", likelies["album"], album_info.album) dist.add_string('album', likelies['album'], album_info.album)
# Current or preferred media. # Current or preferred media.
if album_info.media: if album_info.media:
# Preferred media options. # Preferred media options.
patterns = config["match"]["preferred"]["media"].as_str_seq() patterns = config['match']['preferred']['media'].as_str_seq()
patterns = cast(Sequence[str], patterns) options = [re.compile(r'(\d+x)?(%s)' % pat, re.I) for pat in patterns]
options = [re.compile(r"(\d+x)?(%s)" % pat, re.I) for pat in patterns]
if options: if options:
dist.add_priority("media", album_info.media, options) dist.add_priority('media', album_info.media, options)
# Current media. # Current media.
elif likelies["media"]: elif likelies['media']:
dist.add_equality("media", album_info.media, likelies["media"]) dist.add_equality('media', album_info.media, likelies['media'])
# Mediums. # Mediums.
if likelies["disctotal"] and album_info.mediums: if likelies['disctotal'] and album_info.mediums:
dist.add_number("mediums", likelies["disctotal"], album_info.mediums) dist.add_number('mediums', likelies['disctotal'], album_info.mediums)
# Prefer earliest release. # Prefer earliest release.
if album_info.year and config["match"]["preferred"]["original_year"]: if album_info.year and config['match']['preferred']['original_year']:
# Assume 1889 (earliest first gramophone discs) if we don't know the # Assume 1889 (earliest first gramophone discs) if we don't know the
# original year. # original year.
original = album_info.original_year or 1889 original = album_info.original_year or 1889
diff = abs(album_info.year - original) diff = abs(album_info.year - original)
diff_max = abs(datetime.date.today().year - original) diff_max = abs(datetime.date.today().year - original)
dist.add_ratio("year", diff, diff_max) dist.add_ratio('year', diff, diff_max)
# Year. # Year.
elif likelies["year"] and album_info.year: elif likelies['year'] and album_info.year:
if likelies["year"] in (album_info.year, album_info.original_year): if likelies['year'] in (album_info.year, album_info.original_year):
# No penalty for matching release or original year. # No penalty for matching release or original year.
dist.add("year", 0.0) dist.add('year', 0.0)
elif album_info.original_year: elif album_info.original_year:
# Prefer matchest closest to the release year. # Prefer matchest closest to the release year.
diff = abs(likelies["year"] - album_info.year) diff = abs(likelies['year'] - album_info.year)
diff_max = abs( diff_max = abs(datetime.date.today().year -
datetime.date.today().year - album_info.original_year album_info.original_year)
) dist.add_ratio('year', diff, diff_max)
dist.add_ratio("year", diff, diff_max)
else: else:
# Full penalty when there is no original year. # Full penalty when there is no original year.
dist.add("year", 1.0) dist.add('year', 1.0)
# Preferred countries. # Preferred countries.
patterns = config["match"]["preferred"]["countries"].as_str_seq() patterns = config['match']['preferred']['countries'].as_str_seq()
patterns = cast(Sequence[str], patterns)
options = [re.compile(pat, re.I) for pat in patterns] options = [re.compile(pat, re.I) for pat in patterns]
if album_info.country and options: if album_info.country and options:
dist.add_priority("country", album_info.country, options) dist.add_priority('country', album_info.country, options)
# Country. # Country.
elif likelies["country"] and album_info.country: elif likelies['country'] and album_info.country:
dist.add_string("country", likelies["country"], album_info.country) dist.add_string('country', likelies['country'], album_info.country)
# Label. # Label.
if likelies["label"] and album_info.label: if likelies['label'] and album_info.label:
dist.add_string("label", likelies["label"], album_info.label) dist.add_string('label', likelies['label'], album_info.label)
# Catalog number. # Catalog number.
if likelies["catalognum"] and album_info.catalognum: if likelies['catalognum'] and album_info.catalognum:
dist.add_string( dist.add_string('catalognum', likelies['catalognum'],
"catalognum", likelies["catalognum"], album_info.catalognum album_info.catalognum)
)
# Disambiguation. # Disambiguation.
if likelies["albumdisambig"] and album_info.albumdisambig: if likelies['albumdisambig'] and album_info.albumdisambig:
dist.add_string( dist.add_string('albumdisambig', likelies['albumdisambig'],
"albumdisambig", likelies["albumdisambig"], album_info.albumdisambig album_info.albumdisambig)
)
# Album ID. # Album ID.
if likelies["mb_albumid"]: if likelies['mb_albumid']:
dist.add_equality( dist.add_equality('album_id', likelies['mb_albumid'],
"album_id", likelies["mb_albumid"], album_info.album_id album_info.album_id)
)
# Tracks. # Tracks.
dist.tracks = {} dist.tracks = {}
for item, track in mapping.items(): for item, track in mapping.items():
dist.tracks[track] = track_distance(item, track, album_info.va) dist.tracks[track] = track_distance(item, track, album_info.va)
dist.add("tracks", dist.tracks[track].distance) dist.add('tracks', dist.tracks[track].distance)
# Missing tracks. # Missing tracks.
for _ in range(len(album_info.tracks) - len(mapping)): for i in range(len(album_info.tracks) - len(mapping)):
dist.add("missing_tracks", 1.0) dist.add('missing_tracks', 1.0)
# Unmatched tracks. # Unmatched tracks.
for _ in range(len(items) - len(mapping)): for i in range(len(items) - len(mapping)):
dist.add("unmatched_tracks", 1.0) dist.add('unmatched_tracks', 1.0)
# Plugins. # Plugins.
dist.update(plugins.album_distance(items, album_info, mapping)) dist.update(plugins.album_distance(items, album_info, mapping))
@@ -326,7 +263,7 @@ def distance(
return dist return dist
def match_by_id(items: Iterable[Item]): def match_by_id(items):
"""If the items are tagged with a MusicBrainz album ID, returns an """If the items are tagged with a MusicBrainz album ID, returns an
AlbumInfo object for the corresponding album. Otherwise, returns AlbumInfo object for the corresponding album. Otherwise, returns
None. None.
@@ -337,22 +274,20 @@ def match_by_id(items: Iterable[Item]):
try: try:
first = next(albumids) first = next(albumids)
except StopIteration: except StopIteration:
log.debug("No album ID found.") log.debug('No album ID found.')
return None return None
# Is there a consensus on the MB album ID? # Is there a consensus on the MB album ID?
for other in albumids: for other in albumids:
if other != first: if other != first:
log.debug("No album ID consensus.") log.debug('No album ID consensus.')
return None return None
# If all album IDs are equal, look up the album. # If all album IDs are equal, look up the album.
log.debug("Searching for discovered album ID: {0}", first) log.debug('Searching for discovered album ID: {0}', first)
return hooks.album_for_mbid(first) return hooks.album_for_mbid(first)
def _recommendation( def _recommendation(results):
results: Sequence[Union[AlbumMatch, TrackMatch]],
) -> Recommendation:
"""Given a sorted list of AlbumMatch or TrackMatch objects, return a """Given a sorted list of AlbumMatch or TrackMatch objects, return a
recommendation based on the results' distances. recommendation based on the results' distances.
@@ -366,19 +301,17 @@ def _recommendation(
# Basic distance thresholding. # Basic distance thresholding.
min_dist = results[0].distance min_dist = results[0].distance
if min_dist < config["match"]["strong_rec_thresh"].as_number(): if min_dist < config['match']['strong_rec_thresh'].as_number():
# Strong recommendation level. # Strong recommendation level.
rec = Recommendation.strong rec = Recommendation.strong
elif min_dist <= config["match"]["medium_rec_thresh"].as_number(): elif min_dist <= config['match']['medium_rec_thresh'].as_number():
# Medium recommendation level. # Medium recommendation level.
rec = Recommendation.medium rec = Recommendation.medium
elif len(results) == 1: elif len(results) == 1:
# Only a single candidate. # Only a single candidate.
rec = Recommendation.low rec = Recommendation.low
elif ( elif results[1].distance - min_dist >= \
results[1].distance - min_dist config['match']['rec_gap_thresh'].as_number():
>= config["match"]["rec_gap_thresh"].as_number()
):
# Gap between first two candidates is large. # Gap between first two candidates is large.
rec = Recommendation.low rec = Recommendation.low
else: else:
@@ -391,60 +324,48 @@ def _recommendation(
if isinstance(results[0], hooks.AlbumMatch): if isinstance(results[0], hooks.AlbumMatch):
for track_dist in min_dist.tracks.values(): for track_dist in min_dist.tracks.values():
keys.update(list(track_dist.keys())) keys.update(list(track_dist.keys()))
max_rec_view = config["match"]["max_rec"] max_rec_view = config['match']['max_rec']
for key in keys: for key in keys:
if key in list(max_rec_view.keys()): if key in list(max_rec_view.keys()):
max_rec = max_rec_view[key].as_choice( max_rec = max_rec_view[key].as_choice({
{ 'strong': Recommendation.strong,
"strong": Recommendation.strong, 'medium': Recommendation.medium,
"medium": Recommendation.medium, 'low': Recommendation.low,
"low": Recommendation.low, 'none': Recommendation.none,
"none": Recommendation.none, })
}
)
rec = min(rec, max_rec) rec = min(rec, max_rec)
return rec return rec
AnyMatch = TypeVar("AnyMatch", TrackMatch, AlbumMatch) def _sort_candidates(candidates):
def _sort_candidates(candidates: Iterable[AnyMatch]) -> Sequence[AnyMatch]:
"""Sort candidates by distance.""" """Sort candidates by distance."""
return sorted(candidates, key=lambda match: match.distance) return sorted(candidates, key=lambda match: match.distance)
def _add_candidate( def _add_candidate(items, results, info):
items: Sequence[Item],
results: Dict[Any, AlbumMatch],
info: AlbumInfo,
):
"""Given a candidate AlbumInfo object, attempt to add the candidate """Given a candidate AlbumInfo object, attempt to add the candidate
to the output dictionary of AlbumMatch objects. This involves to the output dictionary of AlbumMatch objects. This involves
checking the track count, ordering the items, checking for checking the track count, ordering the items, checking for
duplicates, and calculating the distance. duplicates, and calculating the distance.
""" """
log.debug( log.debug('Candidate: {0} - {1} ({2})',
"Candidate: {0} - {1} ({2})", info.artist, info.album, info.album_id info.artist, info.album, info.album_id)
)
# Discard albums with zero tracks. # Discard albums with zero tracks.
if not info.tracks: if not info.tracks:
log.debug("No tracks.") log.debug('No tracks.')
return return
# Prevent duplicates. # Don't duplicate.
if info.album_id and info.album_id in results: if info.album_id in results:
log.debug("Duplicate.") log.debug('Duplicate.')
return return
# Discard matches without required tags. # Discard matches without required tags.
for req_tag in cast( for req_tag in config['match']['required'].as_str_seq():
Sequence[str], config["match"]["required"].as_str_seq()
):
if getattr(info, req_tag) is None: if getattr(info, req_tag) is None:
log.debug("Ignored. Missing required tag: {0}", req_tag) log.debug('Ignored. Missing required tag: {0}', req_tag)
return return
# Find mapping between the items and the track info. # Find mapping between the items and the track info.
@@ -455,24 +376,18 @@ def _add_candidate(
# Skip matches with ignored penalties. # Skip matches with ignored penalties.
penalties = [key for key, _ in dist] penalties = [key for key, _ in dist]
ignored = cast(Sequence[str], config["match"]["ignored"].as_str_seq()) for penalty in config['match']['ignored'].as_str_seq():
for penalty in ignored:
if penalty in penalties: if penalty in penalties:
log.debug("Ignored. Penalty: {0}", penalty) log.debug('Ignored. Penalty: {0}', penalty)
return return
log.debug("Success. Distance: {0}", dist) log.debug('Success. Distance: {0}', dist)
results[info.album_id] = hooks.AlbumMatch( results[info.album_id] = hooks.AlbumMatch(dist, info, mapping,
dist, info, mapping, extra_items, extra_tracks extra_items, extra_tracks)
)
def tag_album( def tag_album(items, search_artist=None, search_album=None,
items, search_ids=[]):
search_artist: Optional[str] = None,
search_album: Optional[str] = None,
search_ids: List[str] = [],
) -> Tuple[str, str, Proposal]:
"""Return a tuple of the current artist name, the current album """Return a tuple of the current artist name, the current album
name, and a `Proposal` containing `AlbumMatch` candidates. name, and a `Proposal` containing `AlbumMatch` candidates.
@@ -492,19 +407,20 @@ def tag_album(
""" """
# Get current metadata. # Get current metadata.
likelies, consensus = current_metadata(items) likelies, consensus = current_metadata(items)
cur_artist = cast(str, likelies["artist"]) cur_artist = likelies['artist']
cur_album = cast(str, likelies["album"]) cur_album = likelies['album']
log.debug("Tagging {0} - {1}", cur_artist, cur_album) log.debug('Tagging {0} - {1}', cur_artist, cur_album)
# The output result, keys are the MB album ID. # The output result (distance, AlbumInfo) tuples (keyed by MB album
candidates: Dict[Any, AlbumMatch] = {} # ID).
candidates = {}
# Search by explicit ID. # Search by explicit ID.
if search_ids: if search_ids:
for search_id in search_ids: for search_id in search_ids:
log.debug("Searching for album ID: {0}", search_id) log.debug('Searching for album ID: {0}', search_id)
for album_info_for_id in hooks.albums_for_id(search_id): for id_candidate in hooks.albums_for_id(search_id):
_add_candidate(items, candidates, album_info_for_id) _add_candidate(items, candidates, id_candidate)
# Use existing metadata or text search. # Use existing metadata or text search.
else: else:
@@ -513,58 +429,51 @@ def tag_album(
if id_info: if id_info:
_add_candidate(items, candidates, id_info) _add_candidate(items, candidates, id_info)
rec = _recommendation(list(candidates.values())) rec = _recommendation(list(candidates.values()))
log.debug("Album ID match recommendation is {0}", rec) log.debug('Album ID match recommendation is {0}', rec)
if candidates and not config["import"]["timid"]: if candidates and not config['import']['timid']:
# If we have a very good MBID match, return immediately. # If we have a very good MBID match, return immediately.
# Otherwise, this match will compete against metadata-based # Otherwise, this match will compete against metadata-based
# matches. # matches.
if rec == Recommendation.strong: if rec == Recommendation.strong:
log.debug("ID match.") log.debug('ID match.')
return ( return cur_artist, cur_album, \
cur_artist, Proposal(list(candidates.values()), rec)
cur_album,
Proposal(list(candidates.values()), rec),
)
# Search terms. # Search terms.
if not (search_artist and search_album): if not (search_artist and search_album):
# No explicit search terms -- use current metadata. # No explicit search terms -- use current metadata.
search_artist, search_album = cur_artist, cur_album search_artist, search_album = cur_artist, cur_album
log.debug("Search terms: {0} - {1}", search_artist, search_album) log.debug('Search terms: {0} - {1}', search_artist, search_album)
extra_tags = None extra_tags = None
if config["musicbrainz"]["extra_tags"]: if config['musicbrainz']['extra_tags']:
tag_list = config["musicbrainz"]["extra_tags"].get() tag_list = config['musicbrainz']['extra_tags'].get()
extra_tags = {k: v for (k, v) in likelies.items() if k in tag_list} extra_tags = {k: v for (k, v) in likelies.items() if k in tag_list}
log.debug("Additional search terms: {0}", extra_tags) log.debug('Additional search terms: {0}', extra_tags)
# Is this album likely to be a "various artist" release? # Is this album likely to be a "various artist" release?
va_likely = ( va_likely = ((not consensus['artist']) or
(not consensus["artist"]) (search_artist.lower() in VA_ARTISTS) or
or (search_artist.lower() in VA_ARTISTS) any(item.comp for item in items))
or any(item.comp for item in items) log.debug('Album might be VA: {0}', va_likely)
)
log.debug("Album might be VA: {0}", va_likely)
# Get the results from the data sources. # Get the results from the data sources.
for matched_candidate in hooks.album_candidates( for matched_candidate in hooks.album_candidates(items,
items, search_artist, search_album, va_likely, extra_tags search_artist,
): search_album,
va_likely,
extra_tags):
_add_candidate(items, candidates, matched_candidate) _add_candidate(items, candidates, matched_candidate)
log.debug("Evaluating {0} candidates.", len(candidates)) log.debug('Evaluating {0} candidates.', len(candidates))
# Sort and get the recommendation. # Sort and get the recommendation.
candidates_sorted = _sort_candidates(candidates.values()) candidates = _sort_candidates(candidates.values())
rec = _recommendation(candidates_sorted) rec = _recommendation(candidates)
return cur_artist, cur_album, Proposal(candidates_sorted, rec) return cur_artist, cur_album, Proposal(candidates, rec)
def tag_item( def tag_item(item, search_artist=None, search_title=None,
item, search_ids=[]):
search_artist: Optional[str] = None,
search_title: Optional[str] = None,
search_ids: Optional[List[str]] = None,
) -> Proposal:
"""Find metadata for a single track. Return a `Proposal` consisting """Find metadata for a single track. Return a `Proposal` consisting
of `TrackMatch` objects. of `TrackMatch` objects.
@@ -576,31 +485,26 @@ def tag_item(
# Holds candidates found so far: keys are MBIDs; values are # Holds candidates found so far: keys are MBIDs; values are
# (distance, TrackInfo) pairs. # (distance, TrackInfo) pairs.
candidates = {} candidates = {}
rec: Optional[Recommendation] = None
# First, try matching by MusicBrainz ID. # First, try matching by MusicBrainz ID.
trackids = search_ids or [t for t in [item.mb_trackid] if t] trackids = search_ids or [t for t in [item.mb_trackid] if t]
if trackids: if trackids:
for trackid in trackids: for trackid in trackids:
log.debug("Searching for track ID: {0}", trackid) log.debug('Searching for track ID: {0}', trackid)
for track_info in hooks.tracks_for_id(trackid): for track_info in hooks.tracks_for_id(trackid):
dist = track_distance(item, track_info, incl_artist=True) dist = track_distance(item, track_info, incl_artist=True)
candidates[track_info.track_id] = hooks.TrackMatch( candidates[track_info.track_id] = \
dist, track_info hooks.TrackMatch(dist, track_info)
)
# If this is a good match, then don't keep searching. # If this is a good match, then don't keep searching.
rec = _recommendation(_sort_candidates(candidates.values())) rec = _recommendation(_sort_candidates(candidates.values()))
if ( if rec == Recommendation.strong and \
rec == Recommendation.strong not config['import']['timid']:
and not config["import"]["timid"] log.debug('Track ID match.')
):
log.debug("Track ID match.")
return Proposal(_sort_candidates(candidates.values()), rec) return Proposal(_sort_candidates(candidates.values()), rec)
# If we're searching by ID, don't proceed. # If we're searching by ID, don't proceed.
if search_ids: if search_ids:
if candidates: if candidates:
assert rec is not None
return Proposal(_sort_candidates(candidates.values()), rec) return Proposal(_sort_candidates(candidates.values()), rec)
else: else:
return Proposal([], Recommendation.none) return Proposal([], Recommendation.none)
@@ -608,7 +512,7 @@ def tag_item(
# Search terms. # Search terms.
if not (search_artist and search_title): if not (search_artist and search_title):
search_artist, search_title = item.artist, item.title search_artist, search_title = item.artist, item.title
log.debug("Item search terms: {0} - {1}", search_artist, search_title) log.debug('Item search terms: {0} - {1}', search_artist, search_title)
# Get and evaluate candidate metadata. # Get and evaluate candidate metadata.
for track_info in hooks.item_candidates(item, search_artist, search_title): for track_info in hooks.item_candidates(item, search_artist, search_title):
@@ -616,7 +520,7 @@ def tag_item(
candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info) candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info)
# Sort by distance and return with recommendation. # Sort by distance and return with recommendation.
log.debug("Found {0} candidates.", len(candidates)) log.debug('Found {0} candidates.', len(candidates))
candidates_sorted = _sort_candidates(candidates.values()) candidates = _sort_candidates(candidates.values())
rec = _recommendation(candidates_sorted) rec = _recommendation(candidates)
return Proposal(candidates_sorted, rec) return Proposal(candidates, rec)
+252 -557
View File
File diff suppressed because it is too large Load Diff
+41 -114
View File
@@ -1,34 +1,10 @@
# --------------- Main ---------------
library: library.db library: library.db
directory: ~/Music directory: ~/Music
statefile: state.pickle
# --------------- Plugins ---------------
plugins: []
pluginpath: []
# --------------- Import ---------------
clutter: ["Thumbs.DB", ".DS_Store"]
ignore: [".*", "*~", "System Volume Information", "lost+found"]
ignore_hidden: yes
import: import:
# common options
write: yes write: yes
copy: yes copy: yes
move: no move: no
timid: no
quiet: no
log:
# other options
default_action: apply
languages: []
quiet_fallback: skip
none_rec_action: ask
# rare options
link: no link: no
hardlink: no hardlink: no
reflink: no reflink: no
@@ -37,117 +13,76 @@ import:
incremental: no incremental: no
incremental_skip_later: no incremental_skip_later: no
from_scratch: no from_scratch: no
quiet_fallback: skip
none_rec_action: ask
timid: no
log:
autotag: yes autotag: yes
quiet: no
singletons: no singletons: no
default_action: apply
languages: []
detail: no detail: no
flat: no flat: no
group_albums: no group_albums: no
pretend: false pretend: false
search_ids: [] search_ids: []
duplicate_keys:
album: albumartist album
item: artist title
duplicate_action: ask duplicate_action: ask
duplicate_verbose_prompt: no
bell: no bell: no
set_fields: {} set_fields: {}
ignored_alias_types: []
singleton_album_disambig: yes
# --------------- Paths --------------- clutter: ["Thumbs.DB", ".DS_Store"]
ignore: [".*", "*~", "System Volume Information", "lost+found"]
ignore_hidden: yes
replace:
'[\\/]': _
'^\.': _
'[\x00-\x1f]': _
'[<>:"\?\*\|]': _
'\.$': _
'\s+$': ''
'^\s+': ''
'^-': _
path_sep_replace: _ path_sep_replace: _
drive_sep_replace: _ drive_sep_replace: _
asciify_paths: false asciify_paths: false
art_filename: cover art_filename: cover
max_filename_length: 0 max_filename_length: 0
replace:
# Replace bad characters with _
# prohibited in many filesystem paths
'[<>:\?\*\|]': _
# double quotation mark "
'\"': _
# path separators: \ or /
'[\\/]': _
# starting and closing periods
'^\.': _
'\.$': _
# control characters
'[\x00-\x1f]': _
# dash at the start of a filename (causes command line ambiguity)
'^-': _
# Replace bad characters with nothing
# starting and closing whitespace
'\s+$': ''
'^\s+': ''
aunique: aunique:
keys: albumartist album keys: albumartist album
disambiguators: albumtype year label catalognum albumdisambig releasegroupdisambig disambiguators: albumtype year label catalognum albumdisambig releasegroupdisambig
bracket: '[]' bracket: '[]'
sunique: overwrite_null:
keys: artist title album: []
disambiguators: year trackdisambig track: []
bracket: '[]'
# --------------- Tagging ---------------
plugins: []
pluginpath: []
threaded: yes
timeout: 5.0
per_disc_numbering: no per_disc_numbering: no
verbose: 0
terminal_encoding:
original_date: no original_date: no
artist_credit: no artist_credit: no
id3v23: no id3v23: no
va_name: "Various Artists" va_name: "Various Artists"
paths:
default: $albumartist/$album%aunique{}/$track $title
singleton: Non-Album/$artist/$title
comp: Compilations/$album%aunique{}/$track $title
# --------------- Performance ---------------
threaded: yes
timeout: 5.0
# --------------- UI ---------------
verbose: 0
terminal_encoding:
ui: ui:
terminal_width: 80 terminal_width: 80
length_diff_thresh: 10.0 length_diff_thresh: 10.0
color: yes color: yes
colors: colors:
text_success: ['bold', 'green'] text_success: green
text_warning: ['bold', 'yellow'] text_warning: yellow
text_error: ['bold', 'red'] text_error: red
text_highlight: ['bold', 'red'] text_highlight: red
text_highlight_minor: ['white'] text_highlight_minor: lightgray
action_default: ['bold', 'cyan'] action_default: turquoise
action: ['bold', 'cyan'] action: blue
# New Colors
text: ['normal']
text_faint: ['faint']
import_path: ['bold', 'blue']
import_path_items: ['bold', 'blue']
added: ['green']
removed: ['red']
changed: ['yellow']
added_highlight: ['bold', 'green']
removed_highlight: ['bold', 'red']
changed_highlight: ['bold', 'yellow']
text_diff_added: ['bold', 'red']
text_diff_removed: ['bold', 'red']
text_diff_changed: ['bold', 'red']
action_description: ['white']
import:
indentation:
match_header: 2
match_details: 2
match_tracklist: 5
layout: column
# --------------- Search ---------------
format_item: $artist - $album - $title format_item: $artist - $album - $title
format_album: $albumartist - $album format_album: $albumartist - $album
@@ -158,13 +93,14 @@ sort_album: albumartist+ album+
sort_item: artist+ album+ disc+ track+ sort_item: artist+ album+ disc+ track+
sort_case_insensitive: yes sort_case_insensitive: yes
# --------------- Autotagger --------------- paths:
default: $albumartist/$album%aunique{}/$track $title
singleton: Non-Album/$artist/$title
comp: Compilations/$album%aunique{}/$track $title
statefile: state.pickle
overwrite_null:
album: []
track: []
musicbrainz: musicbrainz:
enabled: yes
host: musicbrainz.org host: musicbrainz.org
https: no https: no
ratelimit: 1 ratelimit: 1
@@ -172,13 +108,6 @@ musicbrainz:
searchlimit: 5 searchlimit: 5
extra_tags: [] extra_tags: []
genres: no genres: no
external_ids:
discogs: no
bandcamp: no
spotify: no
deezer: no
beatport: no
tidal: no
match: match:
strong_rec_thresh: 0.04 strong_rec_thresh: 0.04
@@ -218,5 +147,3 @@ match:
ignore_video_tracks: yes ignore_video_tracks: yes
track_length_grace: 10 track_length_grace: 10
track_length_max: 30 track_length_max: 30
album_disambig_fields: data_source media year country label catalognum albumdisambig
singleton_disambig_fields: data_source index track_alt album
+6 -14
View File
@@ -16,20 +16,12 @@
Library. Library.
""" """
from .db import Database, Model, Results from .db import Model, Database
from .query import ( from .query import Query, FieldQuery, MatchQuery, AndQuery, OrQuery
AndQuery,
FieldQuery,
InvalidQueryError,
MatchQuery,
OrQuery,
Query,
)
from .queryparse import (
parse_sorted_query,
query_from_strings,
sort_from_strings,
)
from .types import Type from .types import Type
from .queryparse import query_from_strings
from .queryparse import sort_from_strings
from .queryparse import parse_sorted_query
from .query import InvalidQueryError
# flake8: noqa # flake8: noqa
+238 -432
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More