mirror of
https://github.com/rembo10/headphones.git
synced 2026-09-12 17:51:42 +01:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1edc9cde0 | ||
|
|
ce98d0d6ca | ||
|
|
1bd7cc2ffd | ||
|
|
ad858576aa | ||
|
|
455b7d4940 | ||
|
|
cd14c3f4e2 |
@@ -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
@@ -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
|
||||
@@ -1,39 +1,5 @@
|
||||
# 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
|
||||
Released 15 October 2021
|
||||
|
||||
|
||||
+2
-2
@@ -17,8 +17,8 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
if sys.version_info <= (3, 6):
|
||||
sys.stdout.write("Headphones requires Python >= 3.7\n")
|
||||
if sys.version_info <= (3, 5):
|
||||
sys.stdout.write("Headphones requires Python >= 3.5\n")
|
||||
sys.exit(1)
|
||||
|
||||
# Ensure lib added to path, before any other imports
|
||||
|
||||
+160
-274
@@ -5,6 +5,7 @@
|
||||
%>
|
||||
|
||||
<%def name="headerIncludes()">
|
||||
|
||||
<div id="subhead_container">
|
||||
<div id="back_to_previous_link">
|
||||
<a href="artistPage?ArtistID=${album['ArtistID']}" class="back">« Back to ${album['ArtistName']}</a>
|
||||
@@ -12,47 +13,44 @@
|
||||
<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>
|
||||
|
||||
%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':
|
||||
<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_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_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="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:
|
||||
<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_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_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="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
|
||||
|
||||
<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 class="links">
|
||||
<%
|
||||
alternate_albums = myDB.select("SELECT * from allalbums WHERE AlbumID=? ORDER BY ReleaseDate ASC", [album['AlbumID']])
|
||||
%>
|
||||
%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>
|
||||
<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:
|
||||
%for alternate_album in alternate_albums:
|
||||
<%
|
||||
track_count = len(myDB.select("SELECT * from alltracks WHERE ReleaseID=?", [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']]))
|
||||
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]"
|
||||
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]"
|
||||
|
||||
%>
|
||||
<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
|
||||
%endif
|
||||
<a class="menu_link_edit" id="album_chooser" href="javascript:void(0)"><i class="fa fa-pencil"></i> Choose Alternate Release</a>
|
||||
<div id="dialog" title="Choose an Alternate Release" style="display:none" class="configtable">
|
||||
<div class="links">
|
||||
<%
|
||||
alternate_albums = myDB.select("SELECT * from allalbums WHERE AlbumID=? ORDER BY ReleaseDate ASC", [album['AlbumID']])
|
||||
%>
|
||||
%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>
|
||||
<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>
|
||||
%else:
|
||||
%for alternate_album in alternate_albums:
|
||||
<%
|
||||
track_count = len(myDB.select("SELECT * from alltracks WHERE ReleaseID=?", [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']]))
|
||||
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]"
|
||||
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]"
|
||||
|
||||
%>
|
||||
<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>
|
||||
%endfor
|
||||
%endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<a class="menu_link_edit" id="edit_search_term" href="javascript:void(0)"><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">
|
||||
<form action="editSearchTerm" method="GET" id="editSearchTermForm">
|
||||
<form action="editSearchTerm" method="GET" id="editSearchTerm">
|
||||
<input type="hidden" name="AlbumID" value="${album['AlbumID']}">
|
||||
<div class="row">
|
||||
<%
|
||||
@@ -63,11 +61,10 @@
|
||||
%>
|
||||
<input type="text" value="${search_term}" name="SearchTerm" size="40" />
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
<thead>
|
||||
@@ -91,7 +88,7 @@
|
||||
<div class="table_wrapper">
|
||||
<div id="albumheader" class="clearfix">
|
||||
<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>
|
||||
|
||||
<h1 id="albumname">
|
||||
@@ -109,6 +106,7 @@
|
||||
albumduration = helpers.convert_milliseconds(totalduration)
|
||||
except:
|
||||
albumduration = 'n/a'
|
||||
|
||||
%>
|
||||
<div class="albuminfo">
|
||||
<div id="albumInfo"></div>
|
||||
@@ -189,290 +187,178 @@
|
||||
</%def>
|
||||
|
||||
<%def name="headIncludes()">
|
||||
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
|
||||
<link rel="stylesheet" href="interfaces/default/css/data_table.css">
|
||||
</%def>
|
||||
|
||||
<%def name="javascriptIncludes()">
|
||||
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
|
||||
<script src="js/libs/jquery.dataTables.min.js"></script>
|
||||
<script>
|
||||
// Define a global object for page-specific functions to avoid polluting global scope directly
|
||||
var AlbumPage = AlbumPage || {};
|
||||
|
||||
AlbumPage.search_results = []; // Moved to page-specific scope
|
||||
|
||||
AlbumPage.getAlbumInfo = function() {
|
||||
function getAlbumInfo() {
|
||||
var id = "${album['AlbumID']}";
|
||||
var elem = $("#albumInfo");
|
||||
// Assuming getInfo is defined in common.js
|
||||
if (typeof getInfo === 'function') {
|
||||
getInfo(elem,id,'album');
|
||||
} else {
|
||||
console.warn("getInfo function not found. Album info might not be loaded.");
|
||||
}
|
||||
};
|
||||
getInfo(elem,id,'album');
|
||||
}
|
||||
|
||||
AlbumPage.initDialogs = function() {
|
||||
// General handler for opening dialogs based on data-dialog-id
|
||||
$(document).on('click', '.dialog-trigger', function(e) {
|
||||
e.preventDefault();
|
||||
var dialogId = $(this).data('dialog-id');
|
||||
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
|
||||
function initThisPage() {
|
||||
$('#album_chooser').click(function() {
|
||||
$('#dialog').dialog({
|
||||
width: 500,
|
||||
maxHeight: 500
|
||||
});
|
||||
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({
|
||||
width: "80%",
|
||||
maxHeight: 500,
|
||||
modal: true // Added modal to make it more common practice
|
||||
maxHeight: 500
|
||||
});
|
||||
return false;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
AlbumPage.downloadSpecificRelease = function(i){
|
||||
var release = AlbumPage.search_results[i]; // Get from stored results
|
||||
function downloadSpecificRelease(i){
|
||||
|
||||
var url = "download_specific_release?AlbumID=${album['AlbumID']}" +
|
||||
"&title=" + encodeURIComponent(release.title) +
|
||||
"&size=" + release.size +
|
||||
"&url=" + encodeURIComponent(release.url) +
|
||||
"&provider=" + encodeURIComponent(release.provider) +
|
||||
"&kind=" + encodeURIComponent(release.kind);
|
||||
title = search_results[i].title
|
||||
size = search_results[i].size
|
||||
url = search_results[i].url
|
||||
provider = search_results[i].provider
|
||||
kind = search_results[i].kind
|
||||
|
||||
AlbumPage.ShowSpinner();
|
||||
$.getJSON(url, function(data) {
|
||||
AlbumPage.loader.remove();
|
||||
AlbumPage.feedback.fadeOut();
|
||||
// Assuming refreshSubmenu is defined globally or in common.js
|
||||
if (typeof refreshSubmenu === 'function') {
|
||||
refreshSubmenu();
|
||||
}
|
||||
ShowSpinner();
|
||||
$.getJSON("download_specific_release?AlbumID=${album['AlbumID']}&title="+title+"&size="+size+"&url="+url+"&provider="+provider+"&kind=" + kind, function(data) {
|
||||
loader.remove();
|
||||
feedback.fadeOut();
|
||||
refreshSubmenu();
|
||||
$("#choose_specific_download_dialog").dialog("close");
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
AlbumPage.ShowSpinner = function() {
|
||||
AlbumPage.feedback = $("#ajaxMsg"); // Assign to AlbumPage scope
|
||||
var update = $("#updatebar");
|
||||
function ShowSpinner() {
|
||||
feedback = $("#ajaxMsg");
|
||||
update = $("#updatebar");
|
||||
if ( update.is(":visible") ) {
|
||||
var height = update.height() + 35;
|
||||
AlbumPage.feedback.css("bottom",height + "px");
|
||||
feedback.css("bottom",height + "px");
|
||||
} else {
|
||||
AlbumPage.feedback.removeAttr("style");
|
||||
feedback.removeAttr("style");
|
||||
}
|
||||
AlbumPage.loader = $("<i class='fa fa-refresh fa-spin'></i>"); // Assign to AlbumPage scope
|
||||
AlbumPage.feedback.prepend(AlbumPage.loader);
|
||||
AlbumPage.feedback.fadeIn();
|
||||
};
|
||||
loader = $("<i class='fa fa-refresh fa-spin'></i>");
|
||||
feedback.prepend(loader);
|
||||
feedback.fadeIn();
|
||||
}
|
||||
|
||||
AlbumPage.loadingMessage = false;
|
||||
AlbumPage.spinner_active = false;
|
||||
AlbumPage.loadingtext_active = false;
|
||||
AlbumPage.refreshInterval = null; // Initialize as null
|
||||
AlbumPage.wasLoading = false;
|
||||
AlbumPage.x = 0;
|
||||
var loadingMessage = false;
|
||||
var spinner_active = false;
|
||||
var loadingtext_active = false;
|
||||
var refreshInterval;
|
||||
var wasLoading = false;
|
||||
var x = 0;
|
||||
|
||||
AlbumPage.checkAlbumStatus = function() {
|
||||
function checkAlbumStatus() {
|
||||
$.getJSON("getAlbumjson?AlbumID=${album['AlbumID']}", function(data) {
|
||||
if (data['Status'] === "Loading"){
|
||||
AlbumPage.wasLoading = true;
|
||||
if (data['Status'] == "Loading"){
|
||||
wasLoading = true;
|
||||
$('#albumnamelink').text(data["AlbumTitle"]);
|
||||
$('#artistnamelink').text(data["ArtistName"]);
|
||||
if (AlbumPage.loadingMessage === false){
|
||||
if (loadingMessage == false){
|
||||
$("#ajaxMsg").after( "<div id='ajaxMsg2' class='ajaxMsg'></div>" );
|
||||
// Assuming showArtistMsg is defined globally or in common.js
|
||||
if (typeof showArtistMsg === 'function') {
|
||||
showArtistMsg("Getting album information");
|
||||
}
|
||||
AlbumPage.loadingMessage = true;
|
||||
showArtistMsg("Getting album information");
|
||||
loadingMessage = true;
|
||||
}
|
||||
if (AlbumPage.spinner_active === false){
|
||||
$('#albumname').prepend('<i class="fa fa-refresh fa-spin" id="albumnamespinner"></i>');
|
||||
AlbumPage.spinner_active = true;
|
||||
if (spinner_active == false){
|
||||
$('#albumname').prepend('<i class="fa fa-refresh fa-spin" id="albumnamespinner"></i>')
|
||||
spinner_active = true;
|
||||
}
|
||||
if (AlbumPage.loadingtext_active === false){
|
||||
$('#albumname').append('<h3 id="loadingtext"><i>(Album information is currently being loaded)</i></h3>');
|
||||
AlbumPage.loadingtext_active = true;
|
||||
if (loadingtext_active == false){
|
||||
$('#albumname').append('<h3 id="loadingtext"><i>(Album information is currently being loaded)</i></h3>')
|
||||
loadingtext_active = true;
|
||||
}
|
||||
} else {
|
||||
AlbumPage.x++;
|
||||
if (AlbumPage.x === 10 || AlbumPage.wasLoading || $("#artistname").text().trim() === "Loading") { // Combined conditions
|
||||
if (AlbumPage.refreshInterval) { // Clear only if interval is set
|
||||
clearInterval(AlbumPage.refreshInterval);
|
||||
}
|
||||
location.reload(); // Reload the page to show updated status
|
||||
}
|
||||
else{
|
||||
if (++x === 10) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
var sts = $("#artistname").text().trim();
|
||||
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, {
|
||||
"title-numeric-pre": function ( a ) {
|
||||
// Ensure it handles cases where title attribute might be missing or different
|
||||
var match = a.match(/title="*(-?[0-9\.]+)/);
|
||||
return match ? parseFloat( match[1] ) : -Infinity; // Return a safe default
|
||||
var x = a.match(/title="*(-?[0-9\.]+)/)[1];
|
||||
return parseFloat( x );
|
||||
},
|
||||
|
||||
"title-numeric-asc": function ( a, b ) {
|
||||
return ((a < b) ? -1 : ((a > b) ? 1 : 0));
|
||||
},
|
||||
|
||||
"title-numeric-desc": function ( a, b ) {
|
||||
return ((a < b) ? 1 : ((a > b) ? -1 : 0));
|
||||
}
|
||||
});
|
||||
} );
|
||||
|
||||
$(document).ready(function() {
|
||||
AlbumPage.getAlbumInfo();
|
||||
AlbumPage.initDialogs(); // Initialize dialog triggers
|
||||
AlbumPage.initDataTables(); // Initialize DataTables
|
||||
// Do not use initActions() here unless it's strictly needed and modernized itself.
|
||||
// setTimeout for fancybox is likely not needed if elements are ready or use delegated events.
|
||||
// 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();
|
||||
getAlbumInfo();
|
||||
initThisPage();
|
||||
checkAlbumStatus();
|
||||
refreshInterval = setInterval(function(){
|
||||
checkAlbumStatus();
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
|
||||
+149
-289
@@ -8,35 +8,31 @@
|
||||
<%def name="headerIncludes()">
|
||||
<div id="subhead_container">
|
||||
<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_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':
|
||||
<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:
|
||||
<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
|
||||
|
||||
%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 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 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" id="menu_link_modifyextra" href="javascript:void(0)"><i class="fa fa-pencil"></i> Modify Extras</a>
|
||||
%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
|
||||
|
||||
<div id="dialog" title="Choose Which Extras to Fetch" style="display:none" class="configtable">
|
||||
<form action="getExtras" method="get" id="getExtrasForm">
|
||||
<input type="hidden" name="ArtistID" value="${artist['ArtistID']}">
|
||||
<input type="hidden" name="newstyle" value="true">
|
||||
%for extra in extras:
|
||||
<input type="checkbox" id="extra_${extra}" name="${extra}" value="1" ${extras[extra]} />
|
||||
<label for="extra_${extra}">${string.capwords(extra)}</label><br>
|
||||
%endfor
|
||||
<br>
|
||||
<button type="submit">Fetch Extras</button>
|
||||
</form>
|
||||
</div>
|
||||
<div id="dialog" title="Choose Which Extras to Fetch" style="display:none" class="configtable">
|
||||
<form action="getExtras" method="get" class="form">
|
||||
<input type="hidden" name="ArtistID" value="${artist['ArtistID']}">
|
||||
<input type="hidden" name="newstyle" value="true">
|
||||
%for extra in extras:
|
||||
<input type="checkbox" id="${extra}" name="${extra}" value="1" ${extras[extra]} />${string.capwords(extra)}<br>
|
||||
%endfor
|
||||
<br>
|
||||
<input id="submit" type="submit" value="Fetch Extras">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="home" class="back">« Back to overview</a>
|
||||
@@ -45,7 +41,7 @@
|
||||
<%def name="body()">
|
||||
<div id="artistheader" class="clearfix">
|
||||
<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>
|
||||
<h1 id="artistname">
|
||||
<a href="http://musicbrainz.org/artist/${artist['ArtistID']}" id="artistnamelink">${artist['ArtistName']}</a>
|
||||
@@ -53,10 +49,10 @@
|
||||
<div id="artistBio"></div>
|
||||
</div>
|
||||
<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']}>
|
||||
<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 value="Wanted">Wanted</option>
|
||||
<option value="WantedNew">Wanted (new only)</option>
|
||||
@@ -64,12 +60,12 @@
|
||||
<option value="Ignored">Ignored</option>
|
||||
<option value="Downloaded">Downloaded</option>
|
||||
</select>
|
||||
<button type="submit" style="display:none;"></button> <%-- Hidden submit to allow form submission via JS --%>
|
||||
<input type="hidden" value="Go">
|
||||
</div>
|
||||
<table class="display" id="album_table">
|
||||
<thead>
|
||||
<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="albumname">Name</th>
|
||||
<th id="reldate">Date</th>
|
||||
@@ -125,24 +121,24 @@
|
||||
|
||||
%>
|
||||
<tr class="grade${grade}">
|
||||
<td id="select"><input type="checkbox" name="${album['AlbumID']}" class="checkbox album-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="select"><input type="checkbox" name="${album['AlbumID']}" class="checkbox" /></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="reldate">${album['ReleaseDate']}</td>
|
||||
<td id="type">${album['Type']}</td>
|
||||
<td id="score">${album['CriticScore']}/${album['UserScore']}</td>
|
||||
<td id="status">${album['Status']}
|
||||
%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'):
|
||||
[<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:
|
||||
[<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
|
||||
%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'):
|
||||
[<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
|
||||
</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 name="headIncludes()">
|
||||
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
|
||||
<link rel="stylesheet" href="interfaces/default/css/data_table.css">
|
||||
</%def>
|
||||
|
||||
<%def name="javascriptIncludes()">
|
||||
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
|
||||
<script src="js/libs/jquery.dataTables.min.js"></script>
|
||||
|
||||
<script>
|
||||
// Define a global object for page-specific functions to avoid polluting global scope directly
|
||||
var ArtistPage = ArtistPage || {};
|
||||
|
||||
ArtistPage.getArtistBio = function() {
|
||||
function getArtistBio() {
|
||||
var id = "${artist['ArtistID']}";
|
||||
var elem = $("#artistBio");
|
||||
// Assuming getInfo is defined in common.js
|
||||
if (typeof getInfo === 'function') {
|
||||
getInfo(elem,id,'artist');
|
||||
} else {
|
||||
console.warn("getInfo function not found. Artist bio might not be loaded.");
|
||||
}
|
||||
};
|
||||
getInfo(elem,id,'artist');
|
||||
}
|
||||
|
||||
ArtistPage.initDialogs = function() {
|
||||
// General handler for opening dialogs based on data-dialog-id
|
||||
$(document).on('click', '.dialog-trigger', function(e) {
|
||||
e.preventDefault();
|
||||
var dialogId = $(this).data('dialog-id');
|
||||
var $dialog = $('#' + dialogId);
|
||||
<%
|
||||
if headphones.CONFIG.SONGKICK_FILTER_ENABLED:
|
||||
songkick_filter_enabled = "true"
|
||||
else:
|
||||
songkick_filter_enabled = "false"
|
||||
|
||||
if ($dialog.length) {
|
||||
$dialog.dialog({
|
||||
width: 500,
|
||||
maxHeight: 500,
|
||||
modal: true // Added modal for better UX
|
||||
});
|
||||
}
|
||||
});
|
||||
if not headphones.CONFIG.SONGKICK_LOCATION:
|
||||
songkick_location = "none"
|
||||
else:
|
||||
songkick_location = headphones.CONFIG.SONGKICK_LOCATION
|
||||
|
||||
// Submit handler for getExtrasForm
|
||||
$('#getExtrasForm').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var $this = $(this);
|
||||
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");
|
||||
});
|
||||
};
|
||||
if headphones.CONFIG.SONGKICK_ENABLED:
|
||||
songkick_enabled = "true"
|
||||
else:
|
||||
songkick_enabled = "false"
|
||||
|
||||
ArtistPage.getArtistsCalendar = function() {
|
||||
%>
|
||||
function getArtistsCalendar() {
|
||||
var template, calendarDomNode;
|
||||
|
||||
calendarDomNode = $("#artistCalendar");
|
||||
|
||||
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
|
||||
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=?",
|
||||
$.getJSON("https://api.songkick.com/api/3.0/artists/mbid:${artist['ArtistID']}/calendar.json?apikey=${headphones.CONFIG.SONGKICK_APIKEY}&jsoncallback=?",
|
||||
function(data){
|
||||
if (data['resultsPage'] && data['resultsPage'].totalEntries >= 1 && data['resultsPage'].results && data['resultsPage'].results.event) {
|
||||
var events = data.resultsPage.results.event;
|
||||
if (songkick_filter_enabled && songkick_location) {
|
||||
events = $.grep(events, function(element,index){
|
||||
return element.venue && element.venue.metroArea && element.venue.metroArea.id == songkick_location;
|
||||
if (data['resultsPage'].totalEntries >= 1) {
|
||||
if (${songkick_filter_enabled}) {
|
||||
data.resultsPage.results.event = $.grep(data.resultsPage.results.event, function(element,index){
|
||||
return element.venue.metroArea.id == ${songkick_location};
|
||||
});
|
||||
}
|
||||
|
||||
if (events.length > 0) {
|
||||
if (data.resultsPage.results.event.length > 0) {
|
||||
var tourDate;
|
||||
|
||||
calendarDomNode.show();
|
||||
$("#artistImg").addClass('on-tour');
|
||||
|
||||
$.each(events, function(i, event) {
|
||||
var tourDate = template;
|
||||
tourDate = tourDate.replace('URI', event.uri || '#');
|
||||
tourDate = tourDate.replace('NAME', event.displayName || 'N/A');
|
||||
tourDate = tourDate.replace('LOC', (event.location && event.location.city) ? event.location.city : 'N/A');
|
||||
jQuery.each(data.resultsPage.results.event, function(i, event) {
|
||||
tourDate = template;
|
||||
tourDate = tourDate.replace('URI',event.uri);
|
||||
tourDate = tourDate.replace('NAME',event.displayName);
|
||||
tourDate = tourDate.replace('LOC',event.location.city);
|
||||
|
||||
calendarDomNode.append(tourDate);
|
||||
});
|
||||
|
||||
calendarDomNode.append('<li><img src="interfaces/default/images/songkick.png" alt="concerts by songkick" class="sk-logo" /></li>');
|
||||
|
||||
// Handle "More..." button logic for calendar
|
||||
calendarDomNode.each(function() {
|
||||
$(function() {
|
||||
$("#artistCalendar").each(function() {
|
||||
$("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.more").on("click", 'a', function(e) {
|
||||
e.preventDefault(); // Prevent default link behavior
|
||||
});
|
||||
$("li.more").on("click", 'a', function() {
|
||||
var li = $(this).parents("li:first");
|
||||
li.parent().children().show();
|
||||
li.remove();
|
||||
return false;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}).fail(function(jqXHR, textStatus, errorThrown) {
|
||||
console.error("Songkick API call failed: ", textStatus, errorThrown);
|
||||
});
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
ArtistPage.loadingMessage = false;
|
||||
ArtistPage.spinner_active = false;
|
||||
ArtistPage.loadingtext_active = false;
|
||||
ArtistPage.refreshInterval = null; // Initialize as null
|
||||
var loadingMessage = false;
|
||||
var spinner_active = false;
|
||||
var loadingtext_active = false;
|
||||
|
||||
ArtistPage.checkArtistStatus = function() {
|
||||
function checkArtistStatus() {
|
||||
$.getJSON("getArtistjson?ArtistID=${artist['ArtistID']}", function(data) {
|
||||
if (data['Status'] === "Loading"){
|
||||
// Assuming refreshTable() is defined globally or in common.js and it updates the album_table
|
||||
if (typeof refreshTable === 'function') {
|
||||
refreshTable(); // Refresh the album table to show loading states if implemented there
|
||||
}
|
||||
|
||||
if (data['Status'] == "Loading"){
|
||||
refreshTable();
|
||||
$('#artistnamelink').text(data["ArtistName"]);
|
||||
if (ArtistPage.loadingMessage === false){
|
||||
if (loadingMessage == false){
|
||||
$("#ajaxMsg").after( "<div id='ajaxMsg2' class='ajaxMsg'></div>" );
|
||||
if (typeof showArtistMsg === 'function') {
|
||||
showArtistMsg("Getting artist information");
|
||||
}
|
||||
ArtistPage.loadingMessage = true;
|
||||
showArtistMsg("Getting artist information");
|
||||
loadingMessage = true;
|
||||
}
|
||||
if (ArtistPage.spinner_active === false){
|
||||
$('#artistname').prepend('<i class="fa fa-refresh fa-spin" id="artistnamespinner"></i>');
|
||||
ArtistPage.spinner_active = true;
|
||||
if (spinner_active == false){
|
||||
$('#artistname').prepend('<i class="fa fa-refresh fa-spin" id="artistnamespinner"></i>')
|
||||
spinner_active = true;
|
||||
}
|
||||
if (ArtistPage.loadingtext_active === false){
|
||||
$('#artistname').append('<h3 id="loadingtext"><i>(Album information for this artist is currently being loaded)</i></h3>');
|
||||
ArtistPage.loadingtext_active = true;
|
||||
if (loadingtext_active == false){
|
||||
$('#artistname').append('<h3 id="loadingtext"><i>(Album information for this artist is currently being loaded)</i></h3>')
|
||||
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) {
|
||||
console.error("Error checking artist status: ", textStatus, errorThrown);
|
||||
});
|
||||
};
|
||||
else{
|
||||
$('#artistnamespinner').remove()
|
||||
$('#loadingtext').remove()
|
||||
$('#ajaxMsg2').remove()
|
||||
spinner_active = false
|
||||
loadingtext_active = false
|
||||
loadingMessage = false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ArtistPage.initDataTables = function() {
|
||||
$('#album_table').DataTable({
|
||||
"destroy": true, // bDestroy -> destroy
|
||||
"columns": [ // aoColumns -> columns
|
||||
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
|
||||
function initThisPage() {
|
||||
$('#menu_link_getextra').click(function(event) {
|
||||
$('#dialog').dialog();
|
||||
event.preventDefault();
|
||||
});
|
||||
// Assuming resetFilters is defined globally or in common.js
|
||||
if (typeof resetFilters === 'function') {
|
||||
resetFilters("albums");
|
||||
}
|
||||
};
|
||||
$('#menu_link_modifyextra').click(function(event) {
|
||||
$('#dialog').dialog();
|
||||
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() {
|
||||
// Init common actions if they are not already handled in base.html document.ready
|
||||
// if (typeof initActions === 'function') {
|
||||
// initActions();
|
||||
// }
|
||||
|
||||
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();
|
||||
initActions();
|
||||
initThisPage();
|
||||
getArtistBio();
|
||||
if( ${songkick_enabled} ){
|
||||
getArtistsCalendar();
|
||||
}
|
||||
|
||||
// Artist Status Polling
|
||||
ArtistPage.checkArtistStatus(); // Initial check
|
||||
ArtistPage.refreshInterval = setInterval(function(){
|
||||
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'));
|
||||
});
|
||||
|
||||
checkArtistStatus();
|
||||
setInterval(function(){
|
||||
checkArtistStatus();
|
||||
}, 1500);
|
||||
});
|
||||
|
||||
</script>
|
||||
</%def>
|
||||
|
||||
+119
-159
@@ -2,182 +2,142 @@
|
||||
import headphones
|
||||
from headphones import version
|
||||
%>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<!doctype html>
|
||||
<!--[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>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
|
||||
<title>Headphones - ${title}</title>
|
||||
<meta name="description" content="Headphones 'default' interface - made by Elmar Kouwenhoven">
|
||||
<meta name="author" content="Elmar Kouwenhoven">
|
||||
<title>Headphones - ${title}</title>
|
||||
<meta name="description" content="Headphones 'default' interface - made by Elmar Kouwenhoven">
|
||||
<meta name="author" content="Elmar Kouwenhoven">
|
||||
|
||||
<link rel="icon" href="images/favicon.ico">
|
||||
<link rel="apple-touch-icon" href="images/headphoneslogo.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<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()}
|
||||
<link rel="shortcut icon" href="images/favicon.ico">
|
||||
<link rel="apple-touch-icon" href="images/headphoneslogo.png">
|
||||
|
||||
<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>
|
||||
<body>
|
||||
<div id="container">
|
||||
<div id="ajaxMsg" class="ajaxMsg"></div>
|
||||
<div id="container">
|
||||
<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:
|
||||
<div id="updatebar">
|
||||
You're running an unknown version of Headphones. <a href="update">Update</a> or
|
||||
<a href="#" class="close-updatebar">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="#" class="close-updatebar">Close</a>
|
||||
</div>
|
||||
% endif
|
||||
<header>
|
||||
<div class="wrapper">
|
||||
<div id="logo">
|
||||
<a href="home"><img src="images/headphoneslogo.png" alt="headphones" width="64"></a>
|
||||
</div>
|
||||
<ul id="nav">
|
||||
<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"><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 class="wrapper">
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" class="main">
|
||||
<div id="subhead">
|
||||
${next.headerIncludes()}
|
||||
</div>
|
||||
${next.body()}
|
||||
</main>
|
||||
<div id="main" class="main">
|
||||
<div id="subhead">
|
||||
${next.headerIncludes()}
|
||||
</div>
|
||||
${next.body()}
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div id="info">
|
||||
<small>
|
||||
<a href="https://github.com/rembo10/headphones"><i class="fa fa-headphones"></i> Website</a> |
|
||||
%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> |
|
||||
%endif
|
||||
<a href="https://github.com/rembo10/headphones/wiki/TroubleShooting"><i class="fa fa-ambulance"></i> Help</a>
|
||||
</small>
|
||||
</div>
|
||||
<div id="actions">
|
||||
<small>
|
||||
<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="#" 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>
|
||||
</small>
|
||||
</div>
|
||||
<div id="version">
|
||||
Version: <em>${headphones.CURRENT_VERSION}</em>
|
||||
%if version.HEADPHONES_VERSION != 'master':
|
||||
(${version.HEADPHONES_VERSION})
|
||||
%endif
|
||||
%if headphones.CONFIG.GIT_BRANCH != 'master':
|
||||
(${headphones.CONFIG.GIT_BRANCH})
|
||||
%endif
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
<footer>
|
||||
<div id="info">
|
||||
<small>
|
||||
<a href="https://github.com/rembo10/headphones"><i class="fa fa-headphones"></i> Website</a> |
|
||||
%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> |
|
||||
%endif
|
||||
<a href="https://github.com/rembo10/headphones/wiki/TroubleShooting"><i class="fa fa-ambulance"></i> Help</a>
|
||||
</small>
|
||||
</div>
|
||||
<div id="actions">
|
||||
<small>
|
||||
<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="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>
|
||||
</div>
|
||||
<div id="version">
|
||||
Version: <em>${headphones.CURRENT_VERSION}</em>
|
||||
%if version.HEADPHONES_VERSION != 'master':
|
||||
(${version.HEADPHONES_VERSION})
|
||||
%endif
|
||||
%if headphones.CONFIG.GIT_BRANCH != 'master':
|
||||
(${headphones.CONFIG.GIT_BRANCH})
|
||||
%endif
|
||||
</div>
|
||||
</footer>
|
||||
<a href="#main" id="toTop"><i class="fa fa-angle-double-up"></i> <span>Back to top</span></a>
|
||||
</div>
|
||||
|
||||
<script src="js/libs/jquery-3.7.1.min.js"></script>
|
||||
<script src="js/libs/jquery-ui.min.js"></script>
|
||||
<script src="js/common.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/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">
|
||||
$(document).ready(function() {
|
||||
// Focus on the first form input that's not hidden
|
||||
$('form:first *:input[type!=hidden]:first').focus();
|
||||
<!-- Persist search type using local storage -->
|
||||
<script type="text/javascript">
|
||||
|
||||
// Persist search type using local storage
|
||||
try {
|
||||
var type = window.localStorage.getItem('search_type') || "artist";
|
||||
$("#search_type").val(type);
|
||||
} catch (e) {
|
||||
console.error("Local Storage not available or error accessing it:", e);
|
||||
}
|
||||
$(document).ready(function() {
|
||||
$('form:first *:input[type!=hidden]:first').focus();
|
||||
try{
|
||||
var type = window.localStorage.getItem('search_type') || "artist";
|
||||
$("#search_type").val(type);
|
||||
} catch(e) {
|
||||
}
|
||||
});
|
||||
|
||||
// Modernized event listener for closing update bar (delegated)
|
||||
// Uses event delegation for robustness: attaches handler to parent (#container)
|
||||
// which listens for clicks on elements with class .close-updatebar
|
||||
$('#container').on('click', '.close-updatebar', function(e) {
|
||||
e.preventDefault(); // Prevent default link behavior
|
||||
$(this).closest('#updatebar').slideUp('slow');
|
||||
});
|
||||
$('select[id=search_type]').change(function() {
|
||||
var type = $(this).val()
|
||||
try{
|
||||
window.localStorage.setItem('search_type', type);
|
||||
} catch(e) {
|
||||
}
|
||||
});
|
||||
|
||||
// Modernized event listener for "Check for new version" (delegated)
|
||||
$('#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>
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
+2448
-717
File diff suppressed because it is too large
Load Diff
+1482
-649
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
||||
<%inherit file="base.html" />
|
||||
|
||||
<%def name="body()">
|
||||
<div class="title">
|
||||
<h1 class="clearfix"><i class="fa fa-users"></i> Artists You Might Like</h1>
|
||||
@@ -8,54 +7,9 @@
|
||||
<div class="cloudtag">
|
||||
<ul id="cloud">
|
||||
%for artist in cloudlist:
|
||||
<%--
|
||||
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>
|
||||
<li><a href="addArtist?artistid=${artist['ArtistID']}" class="tag${artist['Count']}">${artist['ArtistName']}</a></li>
|
||||
%endfor
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</%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>
|
||||
|
||||
@@ -56,8 +56,6 @@
|
||||
fileid = 'torrent'
|
||||
if item['URL'].find('codeshy') != -1:
|
||||
fileid = 'nzb'
|
||||
if item['URL'].find('bandcamp') != -1:
|
||||
fileid = 'bandcamp'
|
||||
|
||||
folder = 'Folder: ' + item['FolderName']
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
<table class="display" id="artist_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="column-albumart"></th> <%-- Changed id to class --%>
|
||||
<th class="column-name">Artist Name</th>
|
||||
<th class="column-status">Status</th>
|
||||
<th class="column-album">Latest Release</th>
|
||||
<th class="column-have">Have</th>
|
||||
<th id="albumart"></th>
|
||||
<th id="name">Artist Name</th>
|
||||
<th id="status">Status</th>
|
||||
<th id="album">Latest Release</th>
|
||||
<th id="have">Have</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -20,52 +20,14 @@
|
||||
</%def>
|
||||
|
||||
<%def name="headIncludes()">
|
||||
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
|
||||
<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 name="javascriptIncludes()">
|
||||
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
|
||||
<script src="js/libs/jquery.unveil.min.js"></script> <%-- Keep if still using for lazy loading --%>
|
||||
<script src="js/libs/jquery.unveil.min.js"></script>
|
||||
<script src="js/libs/jquery.dataTables.min.js"></script>
|
||||
<script>
|
||||
// Encapsulate page-specific logic
|
||||
var IndexPage = IndexPage || {};
|
||||
|
||||
IndexPage.initDataTable = function() {
|
||||
function initThisPage() {
|
||||
$('#artist_table').dataTable({
|
||||
"bDestroy": true,
|
||||
"aoColumnDefs": [
|
||||
@@ -74,17 +36,14 @@
|
||||
"aTargets": [0],
|
||||
"mData":"ArtistID",
|
||||
"mRender": function ( data, type, full ) {
|
||||
// Using a class for the image, and data-src for lazy loading
|
||||
// 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>';
|
||||
return '<div id="artistImg"><img class="albumArt" height="50" width="50" alt="" id="'+ data + '" data-src="artwork/thumbs/artist/' + data + '"/></div>';
|
||||
}
|
||||
},
|
||||
{
|
||||
"aTargets":[1],
|
||||
"mDataProp":"ArtistSortName",
|
||||
"mRender":function (data,type,full) {
|
||||
// Using a class for the span, not an ID
|
||||
return '<span class="artist-sort-name" title="' + full['ArtistID'] + '"></span><a href="artistPage?ArtistID=' + full['ArtistID'] + '">' + full['ArtistName'] + '</a>'
|
||||
return '<span title="' + full['ArtistID'] + '"></span><a href="artistPage?ArtistID=' + full['ArtistID'] + '">' + full['ArtistName'] + '</a>'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -94,52 +53,52 @@
|
||||
"aTargets":[3],
|
||||
"mDataProp":"LatestAlbum",
|
||||
"mRender":function(data,type,full){
|
||||
var artist = full; // Renamed to avoid confusion with outer scope
|
||||
var releasedate = '';
|
||||
var albumdisplay = '<i>None</i>';
|
||||
var grade = 'gradeZ'; // Default grade
|
||||
|
||||
if (artist['ReleaseDate'] && artist['LatestAlbum']) {
|
||||
artist = full;
|
||||
if (artist['ReleaseDate'] && artist['LatestAlbum'])
|
||||
{
|
||||
releasedate = artist['ReleaseDate'];
|
||||
albumdisplay = '<i>' + artist['LatestAlbum'] + '</i> (' + artist['ReleaseDate'] + ')';
|
||||
} else if (artist['LatestAlbum']) {
|
||||
}
|
||||
else if(artist['LatestAlbum'])
|
||||
{
|
||||
releasedate = '';
|
||||
albumdisplay = '<i>' + artist['LatestAlbum'] + '</i>';
|
||||
}
|
||||
|
||||
if (artist['ReleaseInFuture'] === 'True') {
|
||||
else
|
||||
{
|
||||
releasedate = '';
|
||||
albumdisplay = '<i>None</i>';
|
||||
}
|
||||
if (artist['ReleaseInFuture'] === 'True')
|
||||
{
|
||||
grade = 'gradeA';
|
||||
}
|
||||
// artist['Grade'] is used in fnRowCallback, ensure it's set in the data.
|
||||
// If this 'Grade' is only for client-side sorting/filtering, it's fine.
|
||||
// If it affects server-side logic, it should be handled there.
|
||||
full['Grade'] = grade; // Ensure grade is part of the row data for fnRowCallback
|
||||
|
||||
// Using a class for the span, not an ID
|
||||
return '<span class="release-date-sort" title="' + releasedate + '"></span><a href="albumPage?AlbumID=' + full['AlbumID'] + '">' + albumdisplay + '</a>'
|
||||
else
|
||||
{
|
||||
grade = 'gradeZ';
|
||||
}
|
||||
artist['Grade'] = grade;
|
||||
return '<span title="' + releasedate + '"></span><a href="albumPage?AlbumID=' + full['AlbumID'] + '">' + albumdisplay + '</a>'
|
||||
}
|
||||
},
|
||||
{
|
||||
"aTargets":[4],
|
||||
"mDataProp":"HaveTracks",
|
||||
"mRender":function(data,type,full){
|
||||
var percent = 0;
|
||||
var totalTracksDisplay = '?';
|
||||
|
||||
if (full['TotalTracks'] > 0) {
|
||||
percent = (full['HaveTracks']*100.0)/full['TotalTracks'];
|
||||
if (percent > 100) {
|
||||
if(full['TotalTracks'] > 0)
|
||||
{
|
||||
percent = (full['HaveTracks']*100.0)/full['TotalTracks']
|
||||
if(percent > 100){
|
||||
percent = 100;
|
||||
}
|
||||
totalTracksDisplay = full['TotalTracks'];
|
||||
}
|
||||
else
|
||||
{
|
||||
full['TotalTracks'] = '?';
|
||||
percent = 0;
|
||||
}
|
||||
|
||||
// Added ARIA attributes for accessibility
|
||||
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>';
|
||||
return '<span title="' + percent + '"></span><div class="progress-container"><div style="width:' + percent + '%"><div class="havetracks">' + full['HaveTracks'] + '/' + full['TotalTracks'] + '</div></div></div>';
|
||||
}
|
||||
},
|
||||
],
|
||||
@@ -151,73 +110,46 @@
|
||||
"sInfoFiltered":"(filtered from _MAX_ total artists)",
|
||||
"sEmptyTable": " ",
|
||||
},
|
||||
"bStateSave": true, // Retain table state across page loads
|
||||
"bStateSave": true,
|
||||
"iDisplayLength": 50,
|
||||
"sPaginationType": "full_numbers",
|
||||
"bProcessing": true, // Show processing indicator
|
||||
"bServerSide": true, // Enable server-side processing
|
||||
"sAjaxSource": 'getArtists.json', // API endpoint for data
|
||||
"bProcessing": true,
|
||||
"bServerSide": true,
|
||||
"sAjaxSource": 'getArtists.json',
|
||||
"fnRowCallback": function(nRow, aData, iDisplayIndex, iDisplayIndexFull) {
|
||||
// Apply the 'Grade' class from aData to the table row
|
||||
$(nRow).addClass(aData['Grade']);
|
||||
|
||||
// Removed setting duplicate IDs on child elements. Use classes if needed for styling.
|
||||
// For example:
|
||||
// $(nRow).find('td:eq(0)').addClass('albumart-cell');
|
||||
// $(nRow).find('td:eq(1)').addClass('name-cell');
|
||||
// etc.
|
||||
$('td', nRow).closest('tr').addClass(aData['Grade'])
|
||||
nRow.children[0].id = 'albumart';
|
||||
nRow.children[1].id = 'name';
|
||||
nRow.children[2].id = 'status'
|
||||
nRow.children[3].id = 'album'
|
||||
nRow.children[4].id = 'have'
|
||||
return nRow;
|
||||
},
|
||||
"fnServerData": function ( sSource, aoData, fnCallback ) {
|
||||
// Custom function for fetching data, using $.getJSON
|
||||
$.getJSON( sSource, aoData, function (json) {
|
||||
fnCallback(json);
|
||||
}).fail(function(jqXHR, textStatus, errorThrown) {
|
||||
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
|
||||
});
|
||||
/* Add some extra data to the sender */
|
||||
$.getJSON( sSource, aoData, function (json) { fnCallback(json) } )
|
||||
},
|
||||
"fnInitComplete": function(oSettings, json)
|
||||
{
|
||||
},
|
||||
// Removed fnInitComplete as it was empty
|
||||
"fnDrawCallback": function (o) {
|
||||
// Jump to top of page
|
||||
$('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
|
||||
// This is handled in fnDrawCallback now for better integration with DataTables.
|
||||
// $('#artist_table').on("draw.dt", function () {
|
||||
// $("img.albumArt-thumb").unveil();
|
||||
// });
|
||||
$('#artist_table').on("draw.dt", function () {
|
||||
$("img").unveil();
|
||||
});
|
||||
|
||||
// Assuming resetFilters is a global function from common.js
|
||||
if (typeof resetFilters === 'function') {
|
||||
resetFilters("artist or album");
|
||||
} else {
|
||||
console.warn("resetFilters function is not defined.");
|
||||
}
|
||||
};
|
||||
resetFilters("artist or album");
|
||||
}
|
||||
|
||||
$(document).ready(function() {
|
||||
IndexPage.initDataTable();
|
||||
initThisPage();
|
||||
});
|
||||
|
||||
$(window).on('load', function(){
|
||||
// Ensure these functions exist and are necessary.
|
||||
// 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.");
|
||||
}
|
||||
$(window).load(function(){
|
||||
initFancybox();
|
||||
refreshLoadArtist();
|
||||
});
|
||||
</script>
|
||||
</%def>
|
||||
|
||||
@@ -1,539 +1,386 @@
|
||||
/**
|
||||
* @file headphones.js
|
||||
* @brief Main JavaScript file for Headphones web interface.
|
||||
* Contains core UI, API, and utility functions.
|
||||
*/
|
||||
|
||||
var Headphones = Headphones || {};
|
||||
|
||||
// --- Configuration ---
|
||||
Headphones.config = {
|
||||
fallbackImage: "interfaces/default/images/no-cover-art.png", // Generic fallback
|
||||
fallbackArtistImage: "interfaces/default/images/no-cover-artist.png", // Specific artist fallback
|
||||
messageTimeout: 3000 // Default timeout for messages in milliseconds
|
||||
};
|
||||
|
||||
// --- UI Messaging Module ---
|
||||
Headphones.UI = Headphones.UI || {};
|
||||
Headphones.UI.Message = (function() {
|
||||
var $ajaxMsg = $("#ajaxMsg");
|
||||
var $ajaxMsg2 = $("#ajaxMsg2"); // Assuming this is for artist-specific messages
|
||||
var $updateBar = $("#updatebar");
|
||||
|
||||
/**
|
||||
* 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);
|
||||
function getThumb(imgElem,id,type) {
|
||||
|
||||
if ( type == 'artist' ) {
|
||||
var thumbURL = "getThumb?ArtistID=" + id;
|
||||
// var imgURL = "getArtwork?ArtistID=" + id;
|
||||
} else {
|
||||
var thumbURL = "getThumb?AlbumID=" + id;
|
||||
// var imgURL = "getArtwork?AlbumID=" + id;
|
||||
}
|
||||
// Get Data from the cache by Artist ID
|
||||
$.ajax({
|
||||
url: thumbURL,
|
||||
cache: true,
|
||||
success: function(data){
|
||||
if ( data == "" ) {
|
||||
var imageUrl = "interfaces/default/images/no-cover-artist.png";
|
||||
}
|
||||
else {
|
||||
var imageUrl = data;
|
||||
}
|
||||
$(imgElem).attr("src",imageUrl).hide().fadeIn();
|
||||
// $(imgElem).wrap('<a href="'+ imgURL +'" rel="dialog" title="' + name + '"></a>');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Original getInfo replacement
|
||||
function getInfo(elem, id, type) {
|
||||
Headphones.Images.getInfo($(elem), id, type);
|
||||
function getArtwork(imgElem,id,name,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)
|
||||
// The HTML templates are passing doAjaxCall(url,elem,reload,form).
|
||||
// This wrapper needs to convert those to the new options object.
|
||||
function doAjaxCall(url, elem, reloadType, isFormSubmission) {
|
||||
var options = {
|
||||
url: url,
|
||||
contextElement: elem,
|
||||
reloadType: reloadType
|
||||
};
|
||||
function getInfo(elem,id,type) {
|
||||
|
||||
if ( type == 'artist' ) {
|
||||
var infoURL = "getInfo?ArtistID=" + id;
|
||||
} else {
|
||||
var infoURL = "getInfo?AlbumID=" + id;
|
||||
}
|
||||
// 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) {
|
||||
// This part needs careful migration. The original `doAjaxCall`
|
||||
// had logic like `var formID = "#"+url; var dataString = $(formID).serialize();`
|
||||
// and validation like `if ( $('td#select input[type=checkbox]').length > 0 && !$('td#select input[type=checkbox]').is(':checked') ... )`.
|
||||
// This validation MUST be done *before* calling doAjaxCall in the specific HTML template's JS.
|
||||
// Here, we assume `url` is the form ID if `isFormSubmission` is true.
|
||||
var $form = $('#' + url); // Assuming url is the form ID
|
||||
if ($form.length) {
|
||||
options.data = $form.serialize();
|
||||
} else {
|
||||
console.warn("doAjaxCall: form with ID '" + url + "' not found for submission.");
|
||||
// If the form cannot be found, it should likely be an error.
|
||||
Headphones.UI.Message.show("Form not found for submission.", 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Headphones.API.call(options);
|
||||
function getImageLinks(elem,id,type,unveil) {
|
||||
if ( type == 'artist' ) {
|
||||
var infoURL = "getImageLinks?ArtistID=" + id;
|
||||
} else {
|
||||
var infoURL = "getImageLinks?AlbumID=" + id;
|
||||
}
|
||||
|
||||
// Get Data from the cache by ID
|
||||
$.ajax({
|
||||
url: infoURL,
|
||||
cache: true,
|
||||
dataType: "json",
|
||||
success: function(data){
|
||||
if (!data) {
|
||||
// Invalid response
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
Headphones.API.simpleCall(url);
|
||||
$.ajax(url);
|
||||
}
|
||||
|
||||
function resetFilters(text) {
|
||||
Headphones.Utils.resetFilters(text);
|
||||
function resetFilters(text){
|
||||
if ( $(".dataTables_filter").length > 0 ) {
|
||||
$(".dataTables_filter input").attr("placeholder","filter " + text + "");
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
// It's called by `$(document).ready` in many templates.
|
||||
// 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.
|
||||
$(document).ready(function(){
|
||||
initHeader();
|
||||
});
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
<%def name="headerIncludes()">
|
||||
<div id="subhead_container">
|
||||
<div id="subhead_menu">
|
||||
<%-- Changed href to # and added data-action for JS handling --%>
|
||||
<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_action" href="#" data-action="toggleVerbose" data-success="Log verbosity toggled!"><i class="fa fa-pencil"></i> Toggle Debug Log</a>
|
||||
<a class="menu_link_edit" href="clearLogs"><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>
|
||||
</div>
|
||||
</div>
|
||||
</%def>
|
||||
@@ -20,81 +19,61 @@
|
||||
<table class="display" id="log_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="column-timestamp">Timestamp</th>
|
||||
<th class="column-level">Level</th>
|
||||
<th class="column-message">Message</th>
|
||||
<th id="timestamp">Timestamp</th>
|
||||
<th id="level">Level</th>
|
||||
<th id="message">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
<br>
|
||||
<div class="refresh-controls" align="center">
|
||||
Refresh rate:
|
||||
<select id="refreshrate"> <%-- Removed inline onchange --%>
|
||||
<option value="0" selected="selected">No Refresh</option>
|
||||
<option value="5">5 Seconds</option>
|
||||
<option value="15">15 Seconds</option>
|
||||
<option value="30">30 Seconds</option>
|
||||
<option value="60">60 Seconds</option>
|
||||
<option value="300">5 Minutes</option>
|
||||
<option value="600">10 Minutes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div align="center">Refresh rate:
|
||||
<select id="refreshrate" onchange="setRefresh()">
|
||||
<option value="0" selected="selected">No Refresh</option>
|
||||
<option value="5">5 Seconds</option>
|
||||
<option value="15">15 Seconds</option>
|
||||
<option value="30">30 Seconds</option>
|
||||
<option value="60">60 Seconds</option>
|
||||
<option value="300">5 Minutes</option>
|
||||
<option value="600">10 Minutes</option>
|
||||
</select></div>
|
||||
</%def>
|
||||
|
||||
<%def name="headIncludes()">
|
||||
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
|
||||
<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 name="javascriptIncludes()">
|
||||
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
|
||||
<script src="js/libs/jquery.dataTables.min.js"></script>
|
||||
<script>
|
||||
// Encapsulate page-specific logic
|
||||
var LogsPage = LogsPage || {};
|
||||
$(document).ready(function() {
|
||||
initActions();
|
||||
|
||||
LogsPage.timer = null; // To hold the interval timer ID
|
||||
|
||||
LogsPage.initDataTable = function() {
|
||||
$('#log_table').dataTable( {
|
||||
"bProcessing": true,
|
||||
$('#log_table').dataTable( {
|
||||
"bProcessing": true,
|
||||
"bServerSide": true,
|
||||
"sAjaxSource": 'getLog',
|
||||
"sAjaxSource": 'getLog',
|
||||
"sPaginationType": "full_numbers",
|
||||
"aaSorting": [[0, 'desc']], // Sort by timestamp descending
|
||||
"aaSorting": [[0, 'desc']],
|
||||
"iDisplayLength": 25,
|
||||
"bStateSave": true, // Retain table state across page loads
|
||||
"bStateSave": true,
|
||||
"oLanguage": {
|
||||
"sSearch":"Filter:",
|
||||
"sLengthMenu":"Show _MENU_ lines per page",
|
||||
"sEmptyTable": "No log information available",
|
||||
"sInfo":"Showing _START_ to _END_ of _TOTAL_ lines",
|
||||
"sInfoEmpty":"Showing 0 to 0 of 0 lines",
|
||||
"sInfoFiltered":"(filtered from _MAX_ total lines)"
|
||||
},
|
||||
"sSearch":"Filter:",
|
||||
"sLengthMenu":"Show _MENU_ lines per page",
|
||||
"sEmptyTable": "No log information available",
|
||||
"sInfo":"Showing _START_ to _END_ of _TOTAL_ lines",
|
||||
"sInfoEmpty":"Showing 0 to 0 of 0 lines",
|
||||
"sInfoFiltered":"(filtered from _MAX_ total lines)"},
|
||||
"fnRowCallback": function (nRow, aData, iDisplayIndex, iDisplayIndexFull) {
|
||||
// aData[1] contains the 'Level' from the server
|
||||
if (aData[1] === "ERROR") {
|
||||
$(nRow).addClass("gradeX");
|
||||
$('td', nRow).closest('tr').addClass("gradeX");
|
||||
} else if (aData[1] === "WARNING") {
|
||||
$(nRow).addClass("gradeW");
|
||||
$('td', nRow).closest('tr').addClass("gradeW");
|
||||
} else {
|
||||
$(nRow).addClass("gradeZ");
|
||||
$('td', nRow).closest('tr').addClass("gradeZ");
|
||||
}
|
||||
|
||||
return nRow;
|
||||
},
|
||||
"fnDrawCallback": function (o) {
|
||||
@@ -102,76 +81,30 @@
|
||||
$('html,body').scrollTop(0);
|
||||
},
|
||||
"fnServerData": function ( sSource, aoData, fnCallback ) {
|
||||
// Custom function for fetching data, using $.getJSON
|
||||
$.getJSON(sSource, aoData, function (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();
|
||||
}
|
||||
/* Add some extra data to the sender */
|
||||
$.getJSON(sSource, aoData, function (json) {
|
||||
fnCallback(json)
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</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>
|
||||
|
||||
+115
-224
@@ -6,11 +6,9 @@
|
||||
<%def name="headerIncludes()">
|
||||
<div id="subhead_container">
|
||||
<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="#"><i class="fa fa-pencil"></i> Manage Albums</a>
|
||||
<div id="dialog-manage-albums" title="Choose Album Filter" style="display:none" class="configtable">
|
||||
<a class="menu_link_edit" id="manage_albums" href="javascript:void(0)"><i class="fa fa-pencil"></i> Manage Albums</a>
|
||||
<div id="dialog" title="Choose Album Filter" style="display:none" class="configtable">
|
||||
<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=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>
|
||||
@@ -42,9 +40,8 @@
|
||||
<li><a href="#tabs-4">Force Legacy</a></li>
|
||||
</ul>
|
||||
<div id="tabs-1" class="configtable">
|
||||
<%-- action="musicScan" method="GET" is fine for form submission if full page reload is intended --%>
|
||||
<form action="musicScan" method="GET" id="musicScan">
|
||||
<fieldset>
|
||||
<fieldset>
|
||||
<form action="musicScan" method="GET" id="musicScan">
|
||||
<legend>Scan Music Library</legend>
|
||||
<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
|
||||
@@ -55,22 +52,25 @@
|
||||
</p>
|
||||
<br/>
|
||||
<div class="row">
|
||||
<label for="music_dir_path">Path to directory</label>
|
||||
<%-- Using HTML5 placeholder attribute and proper ID --%>
|
||||
<input type="text" id="music_dir_path" value="${headphones.CONFIG.MUSIC_DIR or ''}" name="path" size="70" placeholder="Enter a Music Directory to scan" />
|
||||
<label for="">Path to directory</label>
|
||||
%if headphones.CONFIG.MUSIC_DIR:
|
||||
<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 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 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>
|
||||
|
||||
</fieldset>
|
||||
<br>
|
||||
<%-- Buttons use classes and data attributes for AJAX --%>
|
||||
<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" class="ajax-button" data-action="musicScan" data-success="Changes Saved Successfully" value="Save Changes without Scanning Library">
|
||||
<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" value="Save Changes without Scanning Library" onclick="doAjaxCall('musicScan',$(this),'tabs',true);return false;" data-success="Changes Saved Successfully">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -81,15 +81,19 @@
|
||||
<p>Enter the username whose artists you want to import:</p>
|
||||
<br/>
|
||||
<div class="row">
|
||||
<label for="lastfm_username">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" />
|
||||
<%-- Changed to use class and data attributes for AJAX --%>
|
||||
<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>
|
||||
<label for="">Username</label>
|
||||
<%
|
||||
if headphones.CONFIG.LASTFM_USERNAME:
|
||||
lastfmvalue = headphones.CONFIG.LASTFM_USERNAME
|
||||
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>
|
||||
</fieldset>
|
||||
<%-- Changed to use class and data attributes for AJAX --%>
|
||||
<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"/>
|
||||
<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"/>
|
||||
</form>
|
||||
<br/>
|
||||
<form action="importLastFMTag" method="GET" id="importLastFMTag">
|
||||
@@ -98,15 +102,16 @@
|
||||
<p>Enter tag from which you want import top artists:</p>
|
||||
<br/>
|
||||
<div class="row">
|
||||
<label for="lastfm_tag">Tag</label>
|
||||
<input type="text" id="lastfm_tag" value="" name="tag" size="18" placeholder="Enter tag"/>
|
||||
<label>Tag</label>
|
||||
<input type="text" value="" onfocus="if
|
||||
(this.value==this.defaultValue) this.value='';" name="tag" id="tag" size="18" />
|
||||
<br/>
|
||||
<label for="lastfm_limit">Limit</label>
|
||||
<input type="text" id="lastfm_limit" value="50" name="limit" size="18" placeholder="50"/>
|
||||
<label>Limit</label>
|
||||
<input type="text" value="50" onfocus="if
|
||||
(this.value==this.defaultValue) this.value='';" name="limit" id="limit" size="18" />
|
||||
</div>
|
||||
</fieldset>
|
||||
<%-- Standard submit button for this form --%>
|
||||
<input type="submit" value="Import Tag"/>
|
||||
<input type="submit" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -116,43 +121,40 @@
|
||||
<fieldset>
|
||||
<legend>Force Search</legend>
|
||||
<div class="links">
|
||||
<%-- All links use classes and data attributes for AJAX --%>
|
||||
<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="#" 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="#" 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="#" 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">
|
||||
<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="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="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="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">
|
||||
%if emptyArtists:
|
||||
<h3>The following artists will be deleted:</h3>
|
||||
|
||||
%for emptyArtist in emptyArtists:
|
||||
<p>${emptyArtist['ArtistName']}</p>
|
||||
%endfor
|
||||
<%-- Button uses class and data attributes for AJAX --%>
|
||||
<input type="button" class="ajax-button" data-action="deleteEmptyArtists" data-success="Empty Artists deleted" data-error="Error deleting empty artists" value="Delete Empty Artists">
|
||||
<input type="button" value="Delete Empty Artists" onclick="doAjaxCall('deleteEmptyArtists',$(this))" data-success="Empty Artists deleted" data-error="Error deleting empty artists">
|
||||
%else:
|
||||
<p>No empty artists found.</p>
|
||||
No empty artists found.
|
||||
%endif
|
||||
</div>
|
||||
<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>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<div class="row" id="post_process_alternate">
|
||||
<label for="alt_dir_path">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="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" />
|
||||
<label>Force Post-Process Albums in Alternate Folder</label>
|
||||
<input type="text" value="" name="dir" id="dir" size="50" />
|
||||
<input type="button" class="btnOpenDialog" value="Submit" />
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<div class="row" id="post_process_single">
|
||||
<label for="album_dir_path">Post-Process Single Folder</label>
|
||||
<input type="text" value="" name="album_dir" id="album_dir_path" size="50" placeholder="Enter album directory" />
|
||||
<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" />
|
||||
<label>Post-Process Single Folder</label>
|
||||
<input type="text" value="" name="album_dir" id="album_dir" size="50" />
|
||||
<input type="button" class="btnOpenDialog" value="Submit" />
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -164,200 +166,89 @@
|
||||
<legend>Force Legacy</legend>
|
||||
<p>Please note that these functions will take a significant amount of time to complete.</p>
|
||||
<div class="links">
|
||||
<%-- All links use classes and data attributes for AJAX --%>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<small>*Warning: If you choose [Force Re-scan Library], any manually ignored/matched artists/albums will be reset to "unmatched".</small>
|
||||
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
</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 id="dialog-confirm"></div>
|
||||
|
||||
</div>
|
||||
</%def>
|
||||
<%def name="javascriptIncludes()">
|
||||
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
|
||||
<script>
|
||||
// Encapsulate page-specific logic
|
||||
var ManagePage = ManagePage || {};
|
||||
|
||||
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 addScanAction() {
|
||||
$('#autoadd').append('<input type="hidden" name="scan" value=1 />');
|
||||
};
|
||||
|
||||
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() {
|
||||
ManagePage.init();
|
||||
initThisPage();
|
||||
});
|
||||
</script>
|
||||
</%def>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<%inherit file="base.html" />
|
||||
<%!
|
||||
# Removed direct DB imports/interactions here, as data should be pre-fetched server-side.
|
||||
# from headphones import db
|
||||
import headphones # Still needed for headphones.LOSSY_MEDIA_FORMATS if used
|
||||
from headphones import db
|
||||
import headphones
|
||||
%>
|
||||
|
||||
<%def name="headerIncludes()">
|
||||
@@ -18,10 +17,9 @@
|
||||
<div id="manageheader" class="title">
|
||||
<h1 class="clearfix"><i class="fa fa-music"></i> Manage Albums</h1>
|
||||
</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
|
||||
<%-- Replaced inline onChange with a class and data attributes for JS handling --%>
|
||||
<select name="action" id="markAlbumActionSelect">
|
||||
<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 value="Wanted">Wanted</option>
|
||||
<option value="WantedNew">Wanted (new only)</option>
|
||||
@@ -30,27 +28,25 @@
|
||||
<option value="Ignored">Ignored</option>
|
||||
<option value="Downloaded">Downloaded</option>
|
||||
</select>
|
||||
<input type="hidden" value="Go"> <%-- This hidden input might be redundant if data is sent via AJAX --%>
|
||||
<input type="hidden" value="Go">
|
||||
</div>
|
||||
<table class="display" id="album_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="select-all-checkbox"><input type="checkbox" id="toggleAllAlbums" /></th> <%-- Added ID for easier targeting --%>
|
||||
<th class="column-albumname">Album</th>
|
||||
<th class="column-artistname">Artist</th>
|
||||
<th class="column-reldate">Date</th>
|
||||
<th class="column-type">Type</th>
|
||||
<th class="column-status">Status</th>
|
||||
<th class="column-have">Have</th>
|
||||
<th class="column-bitrate">Bitrate</th>
|
||||
<th class="column-albumformat">Format</th>
|
||||
<th id="select"><input type="checkbox" onClick="toggle(this)" /></th>
|
||||
<th id="albumname">Album</th>
|
||||
<th id="artistname">Artist</th>
|
||||
<th id="reldate">Date</th>
|
||||
<th id="type">Type</th>
|
||||
<th id="status">Status</th>
|
||||
<th id="have">Have</th>
|
||||
<th id="bitrate">Bitrate</th>
|
||||
<th id="albumformat">Format</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
%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':
|
||||
grade = 'Z'
|
||||
elif album['Status'] == 'Wanted':
|
||||
@@ -62,30 +58,45 @@
|
||||
else:
|
||||
grade = 'A'
|
||||
|
||||
# Use the pre-calculated values from the album object
|
||||
totaltracks_display = album.get('TotalTracks', '?')
|
||||
havetracks_display = album.get('HaveTracks', 0)
|
||||
percent_display = album.get('PercentOwned', 0)
|
||||
bitrate_display = album.get('BitrateDisplay', '') # e.g., '192 kbps'
|
||||
albumformat_display = album.get('AlbumFormat', '') # e.g., 'MP3', 'FLAC', 'Mixed'
|
||||
myDB = db.DBConnection()
|
||||
totaltracks = len(myDB.select('SELECT TrackTitle from tracks WHERE AlbumID=?', [album['AlbumID']]))
|
||||
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']]))
|
||||
|
||||
try:
|
||||
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}">
|
||||
<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 class="column-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 class="column-reldate">${album['ReleaseDate']}</td>
|
||||
<td class="column-type">${album['Type']}</td>
|
||||
<td class="column-status">${album['Status']}</td>
|
||||
<td class="column-have">
|
||||
<span title="${percent_display}"></span> <%-- Using percent_display for title attribute --%>
|
||||
<div class="progress-container" role="progressbar" aria-valuenow="${percent_display | int}" aria-valuemin="0" aria-valuemax="100">
|
||||
<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>
|
||||
<td id="select"><input type="checkbox" name="${album['AlbumID']}" class="checkbox" /></td>
|
||||
<td id="albumname"><a href="albumPage?AlbumID=${album['AlbumID']}">${album['AlbumTitle']}</a></td>
|
||||
<td id="artistname"><a href="artistPage?ArtistID=${album['ArtistID']}">${album['ArtistName']}</a></td>
|
||||
<td id="reldate">${album['ReleaseDate']}</td>
|
||||
<td id="type">${album['Type']}</td>
|
||||
<td id="status">${album['Status']}</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="bitrate">${bitrate}</td>
|
||||
<td id="albumformat">${albumformat}</td>
|
||||
</tr>
|
||||
%endfor
|
||||
</tbody>
|
||||
@@ -95,75 +106,28 @@
|
||||
</%def>
|
||||
|
||||
<%def name="headIncludes()">
|
||||
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
|
||||
<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 name="javascriptIncludes()">
|
||||
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
|
||||
<script src="js/libs/jquery.dataTables.min.js"></script>
|
||||
<script>
|
||||
// Encapsulate page-specific logic
|
||||
var ManageAlbumsPage = ManageAlbumsPage || {};
|
||||
|
||||
ManageAlbumsPage.initDataTable = function() {
|
||||
function initThisPage() {
|
||||
$('#album_table').dataTable({
|
||||
"bDestroy": true,
|
||||
"aoColumns": [
|
||||
null, // Checkbox column (not sortable)
|
||||
null, // Album Name
|
||||
null, // Artist Name
|
||||
null, // Date
|
||||
null, // Type
|
||||
null, // Status
|
||||
{ "sType": "title-numeric"}, // Have (uses title for numeric sort)
|
||||
null, // Bitrate
|
||||
null // Format
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{ "sType": "title-numeric"},
|
||||
null,
|
||||
null
|
||||
],
|
||||
"aoColumnDefs": [
|
||||
{ 'bSortable': false, 'aTargets': [ 0 ] } // Disable sorting for checkbox column
|
||||
{ 'bSortable': false, 'aTargets': [ 0 ] }
|
||||
],
|
||||
"oLanguage": {
|
||||
"sLengthMenu":"Show _MENU_ albums per page",
|
||||
@@ -172,83 +136,19 @@
|
||||
"sInfoEmpty":"Showing 0 to 0 of 0 albums",
|
||||
"sInfoFiltered":"(filtered from _MAX_ total albums)",
|
||||
"sSearch": ""},
|
||||
"bPaginate": false, // All data loaded on one page
|
||||
"aaSorting": [[5, 'desc']], // Default sort by Status descending
|
||||
"bPaginate": false,
|
||||
"aaSorting": [[5, 'desc']],
|
||||
"fnDrawCallback": function (o) {
|
||||
// Jump to top of page
|
||||
$('html,body').scrollTop(0);
|
||||
}
|
||||
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
resetFilters("albums");
|
||||
}
|
||||
$(document).ready(function() {
|
||||
ManageAlbumsPage.initDataTable();
|
||||
ManageAlbumsPage.initActions();
|
||||
|
||||
// Assuming resetFilters is a global function from common.js
|
||||
if (typeof resetFilters === 'function') {
|
||||
resetFilters("albums");
|
||||
}
|
||||
initThisPage();
|
||||
});
|
||||
</script>
|
||||
</%def>
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<%inherit file="base.html" />
|
||||
|
||||
|
||||
|
||||
<%def name="headerIncludes()">
|
||||
<div id="subhead_container">
|
||||
|
||||
@@ -13,10 +15,9 @@
|
||||
<div id="manageheader" class="title">
|
||||
<h1 class="clearfix"><i class="fa fa-music"></i> Manage Artists</h1>
|
||||
</div>
|
||||
<form action="markArtists" method="get" id="markArtistsForm"> <%-- Renamed ID to avoid conflict with markalbum div --%>
|
||||
<div id="markartists_controls"> <%-- More descriptive ID for the controls div --%>
|
||||
<%-- Replaced inline onChange with an ID for JS handling --%>
|
||||
<select name="action" id="markArtistActionSelect">
|
||||
<form action="markArtists" method="get" id="markArtists">
|
||||
<div id="markalbum">
|
||||
<select name="action" onChange="doAjaxCall('markArtists',$(this),'table',true);" data-error="You didn't select any artists">
|
||||
<option disabled="disabled" selected="selected">Choose...</option>
|
||||
<option value="pause">Pause</option>
|
||||
<option value="resume">Resume</option>
|
||||
@@ -24,17 +25,17 @@
|
||||
<option value="delete">Delete</option>
|
||||
</select>
|
||||
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>
|
||||
<table class="display" id="artist_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="select-all-checkbox"><input type="checkbox" id="toggleAllArtists" /></th> <%-- Added ID for easier targeting --%>
|
||||
<th class="column-albumart"></th> <%-- Changed ID to class for consistency and uniqueness --%>
|
||||
<th class="column-name">Artist Name</th>
|
||||
<th class="column-status">Status</th>
|
||||
<th class="column-album">Latest Album</th>
|
||||
<th class="column-lastupdated">Last Updated</th>
|
||||
<th id="select"><input type="checkbox" onClick="toggle(this)" /></th>
|
||||
<th id="albumart"></th>
|
||||
<th id="name">Artist Name</th>
|
||||
<th id="status">Status</th>
|
||||
<th id="album">Latest Album</th>
|
||||
<th id="lastupdated">Last Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -47,7 +48,6 @@
|
||||
else:
|
||||
grade = 'Z'
|
||||
|
||||
# Python logic for display formatting is fine here, as it's not doing DB queries.
|
||||
if artist['ReleaseDate'] and artist['LatestAlbum']:
|
||||
releasedate = artist['ReleaseDate']
|
||||
albumdisplay = '<i>%s</i> (%s)' % (artist['LatestAlbum'], artist['ReleaseDate'])
|
||||
@@ -65,17 +65,12 @@
|
||||
|
||||
%>
|
||||
<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 class="column-albumart">
|
||||
<div class="artistImg-container">
|
||||
<%-- Using data-src for lazy loading with jquery.unveil.min.js and native loading="lazy" --%>
|
||||
<img class="albumArt-thumb" alt="Album art for ${artist['ArtistName']}" data-src="artwork/thumbs/artist/${artist['ArtistID']}" loading="lazy" />
|
||||
</div>
|
||||
</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>
|
||||
<td id="select"><input type="checkbox" name="${artist['ArtistID']}" class="checkbox" /></td>
|
||||
<td id="albumart"><div id="artistImg"><img class="albumArt" id="${artist['ArtistID']}" src="artwork/thumbs/artist/${artist['ArtistID']}" height="50" width="50"></div></td>
|
||||
<td id="name"><span title="${artist['ArtistSortName']}"></span><a href="artistPage?ArtistID=${artist['ArtistID']}">${artist['ArtistName']}</a></td>
|
||||
<td id="status">${artist['Status']}</td>
|
||||
<td id="album"><span title="${releasedate}"></span><a href="albumPage?AlbumID=${artist['AlbumID']}">${albumdisplay}</a></td>
|
||||
<td id="lastupdated">${lastupdated}</td>
|
||||
</tr>
|
||||
%endfor
|
||||
</tbody>
|
||||
@@ -85,142 +80,37 @@
|
||||
</%def>
|
||||
|
||||
<%def name="headIncludes()">
|
||||
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
|
||||
<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 name="javascriptIncludes()">
|
||||
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
|
||||
<script src="js/libs/jquery.dataTables.min.js"></script>
|
||||
<script src="js/libs/jquery.unveil.min.js"></script> <%-- Added for lazy loading --%>
|
||||
<script>
|
||||
// Encapsulate page-specific logic
|
||||
var ManageArtistsPage = ManageArtistsPage || {};
|
||||
|
||||
ManageArtistsPage.initDataTable = function() {
|
||||
function initThisPage() {
|
||||
$('#artist_table').dataTable({
|
||||
"bDestroy": true,
|
||||
"aoColumns": [
|
||||
null, // Checkbox column (not sortable)
|
||||
null, // Album art (not sortable by default)
|
||||
{ "sType": "title-string"}, // Artist Name (uses title for sort)
|
||||
null, // Status
|
||||
{ "sType": "title-string"}, // Latest Album (uses title for sort)
|
||||
null // Last Updated
|
||||
],
|
||||
"aoColumnDefs": [
|
||||
{ 'bSortable': false, 'aTargets': [ 0, 1 ] } // Disable sorting for checkbox and album art columns
|
||||
null,
|
||||
null,
|
||||
{ "sType": "title-string"},
|
||||
null,
|
||||
{ "sType": "title-string"},
|
||||
null
|
||||
],
|
||||
"oLanguage": {
|
||||
"sSearch" : "",
|
||||
"sEmptyTable": " "
|
||||
},
|
||||
"bStateSave": true, // Retain table state across page loads
|
||||
"bPaginate": false, // All data loaded on one page
|
||||
"fnDrawCallback": function (o) {
|
||||
// Re-unveil images after each draw for lazy loading
|
||||
$("img.albumArt-thumb").unveil();
|
||||
}
|
||||
"bStateSave": true,
|
||||
"bPaginate": false
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
resetFilters("artists");
|
||||
}
|
||||
$(document).ready(function() {
|
||||
ManageArtistsPage.initDataTable();
|
||||
ManageArtistsPage.initActions();
|
||||
|
||||
// Assuming resetFilters is a global function from common.js
|
||||
if (typeof resetFilters === 'function') {
|
||||
resetFilters("artists");
|
||||
}
|
||||
initThisPage();
|
||||
});
|
||||
|
||||
// Call initFancybox if it's a global function that needs to run on window load
|
||||
$(window).on('load', function(){
|
||||
if (typeof initFancybox === 'function') {
|
||||
initFancybox();
|
||||
} else {
|
||||
console.warn("initFancybox function is not defined.");
|
||||
}
|
||||
$(window).load(function(){
|
||||
initFancybox();
|
||||
});
|
||||
</script>
|
||||
</%def>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<%inherit file="base.html" />
|
||||
<%!
|
||||
import headphones
|
||||
# Removed direct DB imports/interactions here, as data should be pre-fetched server-side.
|
||||
# from headphones import db, helpers
|
||||
# myDB = db.DBConnection() # This should not be in the template
|
||||
from headphones import db, helpers
|
||||
myDB = db.DBConnection()
|
||||
%>
|
||||
|
||||
<%def name="headerIncludes()">
|
||||
@@ -21,82 +20,73 @@
|
||||
<h1 class="clearfix"><i class="fa fa-music"></i> Manage Manually Matched Albums</h1>
|
||||
</div>
|
||||
|
||||
<table class="display" id="manual_album_table"> <%-- Changed ID for clarity --%>
|
||||
<table class="display" id="artist_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="column-artist">Local Artist</th> <%-- Changed ID to class --%>
|
||||
<th class="column-album">Local Album</th> <%-- Changed ID to class --%>
|
||||
<th class="column-status">Previous Action</th> <%-- Changed ID to class --%>
|
||||
<th id="artist">Local Artist</th>
|
||||
<th id="album">Local Album</th>
|
||||
<th id="status">Previous Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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:
|
||||
<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_album_clean = album['AlbumTitle'].replace('&','%26').replace('+', '%2B').replace("'","%27")
|
||||
%>
|
||||
<td class="column-artist">
|
||||
${album['ArtistName']}<BR>
|
||||
<%-- Use a common class and data attributes to pass info to generic dialog --%>
|
||||
<button type="button" class="reset-button"
|
||||
data-reset-type="artist"
|
||||
data-artist-name="${album['ArtistName']}"
|
||||
data-album-status="${album['AlbumStatus']}"
|
||||
data-old-artist-clean="${old_artist_clean}">
|
||||
(<-) Reset Artist
|
||||
</button>
|
||||
<td id="artist">${album['ArtistName']}<BR>
|
||||
<button id="reset_artist${count_albums}" onClick="reset_Artist(this.id)">(<-) Reset Artist</button>
|
||||
<div id="reset_artist_dialog${count_albums}" title="Reset Artist" style="display:none">
|
||||
<table>
|
||||
<tr><td>Are you sure you want to reset Local Artist: ${album['ArtistName']} to unmatched?</td></tr>
|
||||
<tr><td align="right"><BR>
|
||||
%if album['AlbumStatus'] == "Ignored":
|
||||
<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>
|
||||
%elif album['AlbumStatus'] == "Matched":
|
||||
<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 class="column-album">
|
||||
${album['AlbumTitle']}<BR>
|
||||
<%-- Use a common class and data attributes to pass info to generic dialog --%>
|
||||
<button type="button" class="reset-button"
|
||||
data-reset-type="album"
|
||||
data-artist-name="${album['ArtistName']}"
|
||||
data-album-title="${album['AlbumTitle']}"
|
||||
data-album-status="${album['AlbumStatus']}"
|
||||
data-old-artist-clean="${old_artist_clean}"
|
||||
data-old-album-clean="${old_album_clean}">
|
||||
(<-) Reset Album
|
||||
</button>
|
||||
<td id="album">${album['AlbumTitle']}<BR>
|
||||
<button id="reset_album${count_albums}" onClick="reset_Album(this.id)">(<-) Reset Album</button>
|
||||
<div id="reset_album_dialog${count_albums}" title="Reset Album" style="display:none">
|
||||
<table>
|
||||
<tr><td>Are you sure you want to reset Local Album: ${album['AlbumTitle']} to unmatched?</td></tr>
|
||||
<tr><td align="right"><BR>
|
||||
%if album['AlbumStatus'] == "Ignored":
|
||||
<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>
|
||||
%elif album['AlbumStatus'] == "Matched":
|
||||
<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>
|
||||
%endif
|
||||
</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</td>
|
||||
<td class="column-status">
|
||||
${album['AlbumStatus']}
|
||||
<td id="status">${album['AlbumStatus']}
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<% count_albums+=1 %>
|
||||
%endfor
|
||||
</tbody>
|
||||
</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>
|
||||
</%def>
|
||||
|
||||
<%def name="headIncludes()">
|
||||
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
|
||||
<link rel="stylesheet" href="interfaces/default/css/data_table.css">
|
||||
</%def>
|
||||
|
||||
<%def name="javascriptIncludes()">
|
||||
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
|
||||
<script src="js/libs/jquery.dataTables.min.js"></script>
|
||||
<script>
|
||||
// Encapsulate page-specific logic
|
||||
var ManageManualPage = ManageManualPage || {};
|
||||
|
||||
ManageManualPage.initDataTable = function() {
|
||||
$('#manual_album_table').dataTable({ <%-- Use the updated ID --%>
|
||||
$(document).ready(function() {
|
||||
$('#artist_table').dataTable({
|
||||
"bStateSave": true,
|
||||
"bPaginate": true,
|
||||
"oLanguage": {
|
||||
@@ -106,98 +96,28 @@
|
||||
"sInfoEmpty":"Showing 0 to 0 of 0 albums",
|
||||
"sInfoFiltered":"(filtered from _MAX_ total albums)",
|
||||
"sEmptyTable": " ",
|
||||
},
|
||||
},
|
||||
"sPaginationType": "full_numbers",
|
||||
"fnDrawCallback": function (o) {
|
||||
// Jump to top of page
|
||||
$('html,body').scrollTop(0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
ManageManualPage.initDialogs = function() {
|
||||
// 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();
|
||||
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>
|
||||
</%def>
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
<%def name="headerIncludes()">
|
||||
<div id="subhead_container">
|
||||
<div id="subhead_menu">
|
||||
<%-- Changed inline onclick to a class and data attributes for JS handling --%>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<a href="manage" class="back">« Back to manage overview</a>
|
||||
@@ -19,28 +18,26 @@
|
||||
<div id="manageheader" class="title">
|
||||
<h1 class="clearfix"><i class="fa fa-music"></i> Manage New Artists</h1>
|
||||
</div>
|
||||
<form action="addArtists" method="get" id="addArtistsForm"> <%-- Added ID to the form --%>
|
||||
<div id="new_artists_controls"> <%-- Unique and descriptive ID --%>
|
||||
<select name="action" id="newArtistActionSelect">
|
||||
<form action="addArtists" method="get">
|
||||
<div id="markalbum">
|
||||
<select name="action">
|
||||
<option value="add">(+) ADD Selected Artists</option>
|
||||
<option value="ignore">(-) IGNORE Selected Artists</option>
|
||||
</select>
|
||||
<%-- Changed input type="submit" to button with class for AJAX handling --%>
|
||||
<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>
|
||||
<input type="submit" value="Go">
|
||||
</div>
|
||||
<table class="display" id="artist_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="select-all-checkbox"><input type="checkbox" id="toggleAllNewArtists" /></th> <%-- Added ID for easier targeting --%>
|
||||
<th class="column-name">Artist Name</th> <%-- Changed ID to class --%>
|
||||
<th id="select"><input type="checkbox" onClick="toggle(this)" /></th>
|
||||
<th id="name">Artist Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
%for artist in newartists:
|
||||
<tr class="gradeZ">
|
||||
<%-- Changed name attribute to a consistent 'artist_ids' and value to ArtistID for robust processing --%>
|
||||
<td class="select-artist-checkbox"><input type="checkbox" name="artist_ids" value="${artist['ArtistID']}" class="new-artist-checkbox" /></td>
|
||||
<td class="column-name">${artist['ArtistName']}</a></td>
|
||||
<td id="select"><input type="checkbox" name="${artist['ArtistName']}" class="checkbox" /></td>
|
||||
<td id="name">${artist['ArtistName']}</a></td>
|
||||
</tr>
|
||||
%endfor
|
||||
</tbody>
|
||||
@@ -50,111 +47,22 @@
|
||||
</%def>
|
||||
|
||||
<%def name="headIncludes()">
|
||||
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
|
||||
<link rel="stylesheet" href="interfaces/default/css/data_table.css">
|
||||
</%def>
|
||||
|
||||
<%def name="javascriptIncludes()">
|
||||
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
|
||||
<script src="js/libs/jquery.dataTables.min.js"></script>
|
||||
<script>
|
||||
// Encapsulate page-specific logic
|
||||
var ManageNewArtistsPage = ManageNewArtistsPage || {};
|
||||
|
||||
ManageNewArtistsPage.initDataTable = function() {
|
||||
$(document).ready(function() {
|
||||
$('#artist_table').dataTable({
|
||||
"aaSorting": [[1, 'asc']], // Sort by Artist Name ascending
|
||||
"aaSorting": [[1, 'asc']],
|
||||
"bStateSave": false,
|
||||
"bPaginate": false,
|
||||
"oLanguage": {
|
||||
"sSearch" : "",
|
||||
"sEmptyTable": "No new artist information available"
|
||||
},
|
||||
"fnDrawCallback": function (o) {
|
||||
$('html,body').scrollTop(0); // Jump to top of page on draw
|
||||
}
|
||||
"sSearch" : ""},
|
||||
});
|
||||
};
|
||||
|
||||
ManageNewArtistsPage.initActions = function() {
|
||||
// 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();
|
||||
initActions();
|
||||
});
|
||||
</script>
|
||||
</%def>
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
<%inherit file="base.html" />
|
||||
<%!
|
||||
import headphones
|
||||
# Removed direct DB imports and queries from template.
|
||||
# These operations (fetching artists and creating json_artists)
|
||||
# should be performed in the Python view/controller and passed
|
||||
# to the template as part of its context.
|
||||
# import json
|
||||
# from headphones import db, helpers
|
||||
# myDB = db.DBConnection()
|
||||
# artist_json = {}
|
||||
# counter = 0
|
||||
# artist_list = myDB.action("SELECT ArtistName from artists ORDER BY ArtistName COLLATE NOCASE")
|
||||
# for artist in artist_list:
|
||||
# artist_json[counter] = artist['ArtistName']
|
||||
# counter+=1
|
||||
# json_artists = json.dumps(artist_json)
|
||||
import json
|
||||
from headphones import db, helpers
|
||||
myDB = db.DBConnection()
|
||||
artist_json = {}
|
||||
counter = 0
|
||||
artist_list = myDB.action("SELECT ArtistName from artists ORDER BY ArtistName COLLATE NOCASE")
|
||||
for artist in artist_list:
|
||||
artist_json[counter] = artist['ArtistName']
|
||||
counter+=1
|
||||
json_artists = json.dumps(artist_json)
|
||||
%>
|
||||
|
||||
<%def name="headerIncludes()">
|
||||
@@ -33,109 +29,97 @@
|
||||
<h1 class="clearfix"><i class="fa fa-music"></i> Manage Unmatched Albums</h1>
|
||||
</div>
|
||||
|
||||
<table class="display" id="unmatched_album_table"> <%-- Changed ID for clarity --%>
|
||||
<table class="display" id="artist_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="column-artist">Local Artist</th>
|
||||
<th class="column-album">Local Album</th>
|
||||
<th id="artist">Local Artist</th>
|
||||
<th id="album">Local Album</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% count_albums=0 %> <%-- Still useful for unique IDs if needed, but aiming for generic dialogs --%>
|
||||
<% count_albums=0 %>
|
||||
%for album in unmatchedalbums:
|
||||
<tr class="gradeZ">
|
||||
<%
|
||||
# Pre-escape for direct use in data-attributes where JS string is needed.
|
||||
# URL encoding will happen in JS with encodeURIComponent.
|
||||
old_artist_js_str = album['ArtistName'].replace("'","\\'").replace('"','"')
|
||||
old_album_js_str = album['AlbumTitle'].replace("'","\\'").replace('"','"')
|
||||
old_artist_clean = album['ArtistName'].replace('&','%26').replace("'","%27")
|
||||
old_album_clean = album['AlbumTitle'].replace('&','%26').replace("'","%27")
|
||||
old_artist_js = album['ArtistName'].replace("'","\\'").replace('"','\\"')
|
||||
old_album_js = album['AlbumTitle'].replace("'","\\'").replace('"','\\"')
|
||||
%>
|
||||
<td class="column-artist">
|
||||
${album['ArtistName']}<BR>
|
||||
<%-- Data attributes to pass context to JS --%>
|
||||
<button type="button" class="action-button ignore-artist-button"
|
||||
data-artist-name="${album['ArtistName']}"
|
||||
data-old-artist-js="${old_artist_js_str}">
|
||||
(-) Ignore Artist
|
||||
</button>
|
||||
<td id="artist">${album['ArtistName']}<BR>
|
||||
<button id="ignore_artists${count_albums}" onClick="ignore_Artist(this.id)">(-) Ignore Artist</button>
|
||||
<div id="ignore_artist_dialog${count_albums}" title="Ignore Artist" style="display:none">
|
||||
<table>
|
||||
<tr><td>Are you sure you want to ignore Local Artist: ${album['ArtistName']} from future matching?</td></tr>
|
||||
<tr><td align="right"><BR>
|
||||
<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>
|
||||
</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 class="column-album">
|
||||
${album['AlbumTitle']}<BR>
|
||||
<button type="button" class="action-button ignore-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}">
|
||||
(-) Ignore Album
|
||||
</button>
|
||||
<td id="album">${album['AlbumTitle']}<BR>
|
||||
<button id="ignore_albums${count_albums}" onClick="ignore_Album(this.id)">(-) Ignore Album</button>
|
||||
<div id="ignore_album_dialog${count_albums}" title="Ignore Album" style="display:none">
|
||||
<table>
|
||||
<tr><td>Are you sure you want to ignore Local Album: ${album['AlbumTitle']} from future matching?</td></tr>
|
||||
<tr><td align="right"><BR>
|
||||
<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>
|
||||
</td></tr>
|
||||
</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>
|
||||
</tr>
|
||||
<% count_albums+=1 %>
|
||||
%endfor
|
||||
</tbody>
|
||||
</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>
|
||||
</%def>
|
||||
|
||||
<%def name="headIncludes()">
|
||||
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
|
||||
<link rel="stylesheet" href="interfaces/default/css/data_table.css">
|
||||
</%def>
|
||||
|
||||
<%def name="javascriptIncludes()">
|
||||
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
|
||||
<script src="js/libs/jquery.dataTables.min.js"></script>
|
||||
<script>
|
||||
// Encapsulate page-specific logic
|
||||
var ManageUnmatchedPage = ManageUnmatchedPage || {};
|
||||
|
||||
ManageUnmatchedPage.jsonArtists = ${json_artists | n, unicode}; // Assuming json_artists is passed from backend
|
||||
|
||||
ManageUnmatchedPage.initDataTable = function() {
|
||||
$('#unmatched_album_table').dataTable({ <%-- Use the updated ID --%>
|
||||
$(document).ready(function() {
|
||||
$('#artist_table').dataTable({
|
||||
"bStateSave": true,
|
||||
"bPaginate": true,
|
||||
"oLanguage": {
|
||||
@@ -145,203 +129,133 @@
|
||||
"sInfoEmpty":"Showing 0 to 0 of 0 albums",
|
||||
"sInfoFiltered":"(filtered from _MAX_ total albums)",
|
||||
"sEmptyTable": " ",
|
||||
},
|
||||
},
|
||||
"sPaginationType": "full_numbers",
|
||||
"fnDrawCallback": function (o) {
|
||||
// Jump to top of page
|
||||
$('html,body').scrollTop(0);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
ManageUnmatchedPage.initDialogs = function() {
|
||||
// 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();
|
||||
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>
|
||||
</%def>
|
||||
|
||||
@@ -8,24 +8,24 @@
|
||||
<table class="display" id="searchresults_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="column-albumart"></th> <%-- Changed ID to class --%>
|
||||
<th id="albumart"></th>
|
||||
%if type == 'album':
|
||||
<th class="column-albumname">Album Name</th>
|
||||
<th class="column-artistname-small">Artist Name</th>
|
||||
<th class="column-format">Format</th>
|
||||
<th class="column-tracks">Tracks</th>
|
||||
<th class="column-reldate">Date</th>
|
||||
<th class="column-score-small">Score</th>
|
||||
<th class="column-mbrelid" style="display:none;"</th> <%-- Move display:none to CSS if always hidden --%>
|
||||
<th id="albumname">Album Name</th>
|
||||
<th id="artistnamesmall">Artist Name</th>
|
||||
<th id="format">Format</th>
|
||||
<th id="tracks">Tracks</th>
|
||||
<th id="reldate">Date</th>
|
||||
<th id="scoresmall">Score</th>
|
||||
<th id="mbrelid" style="display:none;"</th>
|
||||
%elif type == 'artist':
|
||||
<th class="column-artistname">Artist Name</th>
|
||||
<th class="column-score">Score</th>
|
||||
<th id="artistname">Artist Name</th>
|
||||
<th id="score">Score</th>
|
||||
%else:
|
||||
<th class="column-seriesname">Series Name</th>
|
||||
<th class="column-type">Type</th>
|
||||
<th class="column-score">Score</th>
|
||||
<th id="seriesname">Series Name</th>
|
||||
<th id="type">Type</th>
|
||||
<th id="score">Score</th>
|
||||
%endif
|
||||
<th class="column-mb"></th>
|
||||
<th id="mb"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -39,55 +39,34 @@
|
||||
|
||||
if type == 'album':
|
||||
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']
|
||||
# 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}">
|
||||
%if type == 'album':
|
||||
<td class="column-albumart album-art-cell"> <%-- Changed ID to class --%>
|
||||
<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>
|
||||
<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>
|
||||
%elif type == 'artist':
|
||||
<td class="column-albumart artist-art-cell"> <%-- Changed ID to class --%>
|
||||
<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>
|
||||
<td id="albumart"><div id="artistImg"><img title="${result['id']}" class="albumArt" height="50" width="50"></div></td>
|
||||
%else:
|
||||
<td class="column-albumart series-art-cell"></td> <%-- No artwork for series --%>
|
||||
<td id="albumart"></td>
|
||||
%endif
|
||||
%if type == 'album':
|
||||
<td class="column-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 class="column-format">${result['formats']}</td>
|
||||
<td class="column-tracks">${result['tracks']}</td>
|
||||
<td class="column-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 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 class="column-mbrelid" style="display:none;">${result['albumid']}</td> <%-- Move display:none to CSS if always hidden --%>
|
||||
<td id="albumname"><a href="addReleaseById?rid=${result['albumid']}&rgid=${result['rgid']}" title="${albuminfo}">${result['title']}</a></td>
|
||||
<td id="artistnamesmall"><a href="addArtist?artistid=${result['id']}" title="${result['uniquename']}">${result['uniquename']}</a></td>
|
||||
<td id="format">${result['formats']}</td>
|
||||
<td id="tracks">${result['tracks']}</td>
|
||||
<td id="reldate">${result['date']}</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 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 id="mbrelid" style="display:none;">${result['albumid']}</td>
|
||||
%elif type == 'artist':
|
||||
<td class="column-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 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="artistname"><a href="addArtist?artistid=${result['id']}" title="${result['uniquename']}">${result['uniquename']}</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 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:
|
||||
<td class="column-seriesname"><a href="addSeries?seriesid=${result['id']}" title="${result['uniquename']}">${result['uniquename']}</a></td>
|
||||
<td class="column-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 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="seriesname"><a href="addSeries?seriesid=${result['id']}" title="${result['uniquename']}">${result['uniquename']}</a></td>
|
||||
<td id="type">${result['type']}</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 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
|
||||
</tr>
|
||||
%endfor
|
||||
@@ -98,77 +77,37 @@
|
||||
</%def>
|
||||
|
||||
<%def name="headIncludes()">
|
||||
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
|
||||
<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 name="javascriptIncludes()">
|
||||
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
|
||||
<script src="js/libs/jquery.unveil.min.js"></script>
|
||||
<script src="js/libs/jquery.dataTables.min.js"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
// Global function to try Cover Art Archive or generic fallback
|
||||
function tryCCA(element, url, type) {
|
||||
function tryCCA(element, url) {
|
||||
element.onerror = function() {
|
||||
element.onerror = null; // Prevent infinite loops
|
||||
element.src = "interfaces/default/images/no-cover-art.png"; // Fallback generic image
|
||||
if (type === 'artist') {
|
||||
// Specific fallback for artists if different
|
||||
// element.src = "interfaces/default/images/no-artist-art.png";
|
||||
}
|
||||
element.onerror = null;
|
||||
element.src = "interfaces/default/images/no-cover-art.png";
|
||||
};
|
||||
if (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
|
||||
}
|
||||
element.src = url;
|
||||
}
|
||||
function getArt() {
|
||||
$("table#searchresults_table tr td#albumart img").each(function(){
|
||||
var id = $(this).attr('title');
|
||||
var image = $(this);
|
||||
|
||||
// Encapsulate page-specific logic
|
||||
var SearchResultsPage = SearchResultsPage || {};
|
||||
|
||||
SearchResultsPage.initDataTable = function() {
|
||||
if (!image.hasClass('done')) {
|
||||
image.addClass('done');
|
||||
getImageLinks(image, id, "${type}", true);
|
||||
}
|
||||
});
|
||||
}
|
||||
function initThisPage() {
|
||||
$('#searchresults_table').dataTable({
|
||||
"bDestroy": true,
|
||||
"aoColumnDefs": [
|
||||
{ 'bSortable': false, 'aTargets': [ 0, 7 ] } // Disable sorting for albumart (0) and MusicBrainz icon (7) columns
|
||||
{ 'bSortable': false, 'aTargets': [ 0 ] }
|
||||
],
|
||||
"oLanguage": {
|
||||
"sLengthMenu":"Show _MENU_ results per page",
|
||||
@@ -179,62 +118,36 @@
|
||||
"sSearch" : ""},
|
||||
"iDisplayLength": 25,
|
||||
"sPaginationType": "full_numbers",
|
||||
"aaSorting": [], // No initial sorting specified, DataTables default
|
||||
"aaSorting": [],
|
||||
"fnDrawCallback": function (o) {
|
||||
// Jump to top of page
|
||||
$('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();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Custom function to get image links and apply them to data-src
|
||||
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");
|
||||
}
|
||||
}
|
||||
$('#searchresults_table').on("draw.dt", function () {
|
||||
getArt();
|
||||
$("img").unveil();
|
||||
});
|
||||
};
|
||||
|
||||
getArt();
|
||||
resetFilters("album");
|
||||
}
|
||||
$(document).ready(function(){
|
||||
initFancybox(); // Assuming initFancybox is a global function
|
||||
SearchResultsPage.initDataTable();
|
||||
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
|
||||
}
|
||||
initFancybox();
|
||||
initThisPage();
|
||||
});
|
||||
</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>
|
||||
|
||||
@@ -1,45 +1,13 @@
|
||||
<%inherit file="base.html"/>
|
||||
|
||||
<%def name="headIncludes()">
|
||||
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
|
||||
<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 name="body()">
|
||||
<div class="table_wrapper" id="shutdown-container"> <%-- Added a container for centering --%>
|
||||
<div id="shutdown-message" class="${'restarting' if message == 'restarting' else 'shutting-down'}"> <%-- Added class for conditional styling --%>
|
||||
<h1><i class="fa fa-refresh fa-spin"></i> Headphones is ${message}</h1>
|
||||
<div class="table_wrapper">
|
||||
<div id="shutdown">
|
||||
<h1><i class="fa fa-refresh fa-spin"></i> Headphones is ${message}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</%def>
|
||||
</%def>
|
||||
@@ -3,10 +3,7 @@
|
||||
<%def name="headerIncludes()">
|
||||
<div id="subhead_container">
|
||||
<div id="subhead_menu">
|
||||
<%-- Changed inline onclick to a class and data attributes for JS handling --%>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</%def>
|
||||
@@ -16,44 +13,38 @@
|
||||
<div id="paddingheader">
|
||||
<h1 class="clearfix"><i class="fa fa-heart"></i> Wanted Albums</h1>
|
||||
</div>
|
||||
<form action="markAlbums" method="get" id="markAlbumsForm"> <%-- Added ID to the form --%>
|
||||
<div id="mark_albums_controls"> <%-- Unique and descriptive ID, moved inline style to CSS --%>
|
||||
<form action="markAlbums" method="get" id="markAlbums">
|
||||
<div id="markalbum" style="top:0;">
|
||||
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 value="Skipped">Skipped</option>
|
||||
<option value="Ignored">Ignored</option>
|
||||
<option value="Downloaded">Downloaded</option>
|
||||
</select>
|
||||
<%-- Replaced hidden input with a visible button if desired, or handle submit via JS --%>
|
||||
<%-- For now, keep the JS-driven approach as close as possible to original logic --%>
|
||||
<input type="hidden" value="Go">
|
||||
</div>
|
||||
<div class="table_wrapper" id="wanted_table_wrapper" >
|
||||
<table class="display" id="wanted_table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="select-all-checkbox"><input type="checkbox" id="toggleAllWanted" /></th> <%-- Added ID for easier targeting --%>
|
||||
<th class="column-albumart"></th>
|
||||
<th class="column-artistname">Artist</th>
|
||||
<th class="column-albumname">Album Name</th>
|
||||
<th class="column-reldate">Release Date</th>
|
||||
<th class="column-type">Type</th>
|
||||
<th id="select"><input type="checkbox" onClick="toggle(this)" /></th>
|
||||
<th id="albumart"></th>
|
||||
<th id="artistname">Artist</th>
|
||||
<th id="albumname">Album Name</th>
|
||||
<th id="reldate">Release Date</th>
|
||||
<th id="type">Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
%for album in wanted:
|
||||
<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 class="column-albumart">
|
||||
<img title="${album['AlbumID']}" height="64" width="64"
|
||||
data-src="artwork/thumbs/album/${album['AlbumID']}"
|
||||
src="interfaces/default/images/no-cover-art.png" <%-- Fallback src --%>
|
||||
alt="Cover art for ${album['AlbumTitle']}" loading="lazy">
|
||||
</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 id="select"><input type="checkbox" name="${album['AlbumID']}" class="checkbox" /></th>
|
||||
<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>
|
||||
<td id="artistname"><a href="artistPage?ArtistID=${album['ArtistID']}">${album['ArtistName']}</a></td>
|
||||
<td id="albumname"><a href="albumPage?AlbumID=${album['AlbumID']}">${album['AlbumTitle']}</a></td>
|
||||
<td id="reldate">${album['ReleaseDate']}</td>
|
||||
<td id="type">${album['Type']}</td>
|
||||
</tr>
|
||||
%endfor
|
||||
</tbody>
|
||||
@@ -65,31 +56,26 @@
|
||||
<h1 class="clearfix"><i class="fa fa-calendar"></i> Upcoming Albums</h1>
|
||||
</div>
|
||||
<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>
|
||||
<tr>
|
||||
<th class="column-albumart"></th>
|
||||
<th class="column-artistname">Artist</th>
|
||||
<th class="column-albumname">Album Name</th>
|
||||
<th class="column-reldate">Release Date</th>
|
||||
<th class="column-type">Type</th>
|
||||
<th class="column-status">Status</th>
|
||||
<th id="albumart"></th>
|
||||
<th id="artistname">Artist</th>
|
||||
<th id="albumname">Album Name</th>
|
||||
<th id="reldate">Release Date</th>
|
||||
<th id="type">Type</th>
|
||||
<th id="status">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
%for album in upcoming:
|
||||
<tr class="gradeZ">
|
||||
<td class="column-albumart">
|
||||
<img title="${album['AlbumID']}" height="64" width="64"
|
||||
data-src="artwork/thumbs/album/${album['AlbumID']}"
|
||||
src="interfaces/default/images/no-cover-art.png" <%-- Fallback src --%>
|
||||
alt="Cover art for ${album['AlbumTitle']}" loading="lazy">
|
||||
</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>
|
||||
<td id="albumart"><img title="${album['AlbumID']}" height="64" width="64" data-src="artwork/thumbs/album/${album['AlbumID']}"></td>
|
||||
<td id="artistname"><a href="artistPage?ArtistID=${album['ArtistID']}">${album['ArtistName']}</a></td>
|
||||
<td id="albumname"><a href="albumPage?AlbumID=${album['AlbumID']}">${album['AlbumTitle']}</a></td>
|
||||
<td id="reldate">${album['ReleaseDate']}</td>
|
||||
<td id="type">${album['Type']}</td>
|
||||
<td id="status">${album['Status']}</td>
|
||||
</tr>
|
||||
%endfor
|
||||
</tbody>
|
||||
@@ -98,154 +84,29 @@
|
||||
</%def>
|
||||
|
||||
<%def name="headIncludes()">
|
||||
${parent.headIncludes()} <%-- Ensure parent head includes are kept --%>
|
||||
<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 name="javascriptIncludes()">
|
||||
${parent.javascriptIncludes()} <%-- Ensure parent javascript includes are kept --%>
|
||||
<script src="js/libs/jquery.unveil.min.js"></script>
|
||||
<script src="js/libs/jquery.dataTables.min.js"></script>
|
||||
<script>
|
||||
// Encapsulate page-specific logic
|
||||
var UpcomingPage = UpcomingPage || {};
|
||||
|
||||
UpcomingPage.initDataTables = function() {
|
||||
// Initialize Wanted Albums table
|
||||
function initThisPage() {
|
||||
$("img").unveil();
|
||||
$('#wanted_table').dataTable({
|
||||
"oLanguage": {
|
||||
"sEmptyTable": "No wanted albums found" // More descriptive empty table message
|
||||
"sEmptyTable": " "
|
||||
},
|
||||
"bDestroy": true,
|
||||
"bFilter": false,
|
||||
"bInfo": 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();
|
||||
}
|
||||
"bPaginate": false
|
||||
});
|
||||
|
||||
// Initialize Upcoming Albums table
|
||||
$('#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();
|
||||
}
|
||||
};
|
||||
|
||||
resetFilters("artists");
|
||||
initActions();
|
||||
}
|
||||
$(document).ready(function() {
|
||||
UpcomingPage.initDataTables();
|
||||
UpcomingPage.initActions();
|
||||
// Initial unveil call for images
|
||||
$("img[data-src]").unveil();
|
||||
initThisPage();
|
||||
});
|
||||
</script>
|
||||
</%def>
|
||||
|
||||
+6
-31
@@ -28,34 +28,11 @@ def getAlbumArt(albumid):
|
||||
|
||||
# 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' % albumid
|
||||
|
||||
data = request.request_json(artwork_path, timeout=20, whitelist_status_code=404)
|
||||
|
||||
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
|
||||
artwork_path = 'https://coverartarchive.org/release-group/%s/front' % albumid
|
||||
artwork = getartwork(artwork_path)
|
||||
if artwork:
|
||||
logger.info("Artwork found at CAA")
|
||||
return artwork_path, artwork
|
||||
|
||||
# Amazon
|
||||
logger.info("Searching for artwork at Amazon")
|
||||
@@ -185,14 +162,12 @@ def getartwork(artwork_path):
|
||||
"url": artwork_path,
|
||||
"w": maxwidth
|
||||
}
|
||||
headers = {"User-Agent": "Headphones"}
|
||||
r = request.request_response(
|
||||
url,
|
||||
params=params,
|
||||
timeout=20,
|
||||
stream=True,
|
||||
whitelist_status_code=404,
|
||||
headers=headers
|
||||
whitelist_status_code=404
|
||||
)
|
||||
if r:
|
||||
for chunk in r.iter_content(chunk_size=1024):
|
||||
|
||||
+6
-2
@@ -474,8 +474,12 @@ class Api(object):
|
||||
# Handle situations where the torrent url contains arguments that are
|
||||
# parsed
|
||||
if kwargs:
|
||||
import urllib.request, urllib.parse, urllib.error
|
||||
import urllib.request, urllib.error, urllib.parse
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
url = urllib.parse.quote(
|
||||
url, safe=":?/=&") + '&' + urllib.parse.urlencode(kwargs)
|
||||
|
||||
|
||||
@@ -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":(\[.*?\]),", html)
|
||||
.group(1)
|
||||
.replace('"', '"'))
|
||||
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
@@ -388,9 +388,9 @@ class Cache(object):
|
||||
else:
|
||||
if dbalbum['Type'] != "part of":
|
||||
data = lastfm.request_lastfm("album.getinfo",
|
||||
artist=helpers.clean_musicbrainz_name(dbalbum['ArtistName']),
|
||||
album=helpers.clean_musicbrainz_name(dbalbum['AlbumTitle']),
|
||||
api_key=LASTFM_API_KEY)
|
||||
artist=helpers.clean_musicbrainz_name(dbalbum['ArtistName']),
|
||||
album=helpers.clean_musicbrainz_name(dbalbum['AlbumTitle']),
|
||||
api_key=LASTFM_API_KEY)
|
||||
else:
|
||||
|
||||
# Series, use actual artist for the release-group
|
||||
@@ -484,7 +484,7 @@ class Cache(object):
|
||||
self.id + '_fanart_' + '.' + helpers.today() + ext)
|
||||
else:
|
||||
artwork_path = os.path.join(self.path_to_art_cache,
|
||||
self.id + '.' + helpers.today() + ext)
|
||||
self.id + '.' + helpers.today() + ext)
|
||||
try:
|
||||
with open(artwork_path, 'wb') as f:
|
||||
f.write(artwork)
|
||||
@@ -545,13 +545,11 @@ class Cache(object):
|
||||
"url": thumb_url,
|
||||
"w": 300
|
||||
}
|
||||
headers = {"User-Agent": "Headphones"}
|
||||
artwork_thumb = request.request_content(
|
||||
url,
|
||||
params=params,
|
||||
timeout=20,
|
||||
whitelist_status_code=404,
|
||||
headers=headers
|
||||
whitelist_status_code=404
|
||||
)
|
||||
if artwork_thumb:
|
||||
with open(thumb_path, 'wb') as f:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+38
-1
@@ -77,7 +77,7 @@ class Quality:
|
||||
toReturn = {}
|
||||
for x in list(Quality.qualityStrings.keys()):
|
||||
toReturn[Quality.compositeStatus(status, x)] = Quality.statusPrefixes[status] + " (" + \
|
||||
Quality.qualityStrings[x] + ")"
|
||||
Quality.qualityStrings[x] + ")"
|
||||
return toReturn
|
||||
|
||||
@staticmethod
|
||||
@@ -102,6 +102,36 @@ class Quality:
|
||||
|
||||
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
|
||||
def assumeQuality(name):
|
||||
if name.lower().endswith(".mp3"):
|
||||
@@ -128,6 +158,13 @@ class Quality:
|
||||
|
||||
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
|
||||
SNATCHED = None
|
||||
SNATCHED_PROPER = None
|
||||
|
||||
+20
-23
@@ -31,6 +31,7 @@ class path(str):
|
||||
def __repr__(self):
|
||||
return 'headphones.config.path(%s)' % self
|
||||
|
||||
|
||||
_CONFIG_DEFINITIONS = {
|
||||
'ADD_ALBUM_ART': (int, 'General', 0),
|
||||
'ADVANCEDENCODER': (str, 'General', ''),
|
||||
@@ -80,7 +81,6 @@ _CONFIG_DEFINITIONS = {
|
||||
'DELUGE_PASSWORD': (str, 'Deluge', ''),
|
||||
'DELUGE_LABEL': (str, 'Deluge', ''),
|
||||
'DELUGE_DONE_DIRECTORY': (str, 'Deluge', ''),
|
||||
'DELUGE_DOWNLOAD_DIRECTORY': (str, 'Deluge', ''),
|
||||
'DELUGE_PAUSED': (int, 'Deluge', 0),
|
||||
'DESTINATION_DIR': (str, 'General', ''),
|
||||
'DETECT_BITRATE': (int, 'General', 0),
|
||||
@@ -156,10 +156,9 @@ _CONFIG_DEFINITIONS = {
|
||||
'KEEP_TORRENT_FILES': (int, 'General', 0),
|
||||
'KEEP_TORRENT_FILES_DIR': (path, 'General', ''),
|
||||
'LASTFM_USERNAME': (str, 'General', ''),
|
||||
'LASTFM_APIKEY': (str, 'General', ''),
|
||||
'LAUNCH_BROWSER': (int, 'General', 1),
|
||||
'LIBRARYSCAN': (int, 'General', 1),
|
||||
'LIBRARYSCAN_INTERVAL': (int, 'General', 24),
|
||||
'LIBRARYSCAN_INTERVAL': (int, 'General', 300),
|
||||
'LMS_ENABLED': (int, 'LMS', 0),
|
||||
'LMS_HOST': (str, 'LMS', ''),
|
||||
'LOG_DIR': (path, 'General', ''),
|
||||
@@ -203,6 +202,9 @@ _CONFIG_DEFINITIONS = {
|
||||
'PIRATEBAY': (int, 'Piratebay', 0),
|
||||
'PIRATEBAY_PROXY_URL': (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_ENABLED': (int, 'Plex', 0),
|
||||
'PLEX_NOTIFY': (int, 'Plex', 0),
|
||||
@@ -239,7 +241,6 @@ _CONFIG_DEFINITIONS = {
|
||||
'QBITTORRENT_PASSWORD': (str, 'QBitTorrent', ''),
|
||||
'QBITTORRENT_USERNAME': (str, 'QBitTorrent', ''),
|
||||
'RENAME_FILES': (int, 'General', 0),
|
||||
'RENAME_SINGLE_DISC_IGNORE': (int, 'General', 0),
|
||||
'RENAME_UNPROCESSED': (bool_int, 'General', 1),
|
||||
'RENAME_FROZEN': (bool_int, 'General', 1),
|
||||
'REPLACE_EXISTING_FOLDERS': (int, 'General', 0),
|
||||
@@ -267,11 +268,6 @@ _CONFIG_DEFINITIONS = {
|
||||
'SONGKICK_ENABLED': (int, 'Songkick', 1),
|
||||
'SONGKICK_FILTER_ENABLED': (int, 'Songkick', 0),
|
||||
'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_HOST': (str, 'Subsonic', ''),
|
||||
'SUBSONIC_PASSWORD': (str, 'Subsonic', ''),
|
||||
@@ -305,8 +301,11 @@ _CONFIG_DEFINITIONS = {
|
||||
'UTORRENT_USERNAME': (str, 'uTorrent', ''),
|
||||
'VERIFY_SSL_CERT': (bool_int, 'Advanced', 1),
|
||||
'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_APIKEY': (str, 'Redacted', ''),
|
||||
'REDACTED_USERNAME': (str, 'Redacted', ''),
|
||||
'REDACTED_PASSWORD': (str, 'Redacted', ''),
|
||||
'REDACTED_RATIO': (str, 'Redacted', ''),
|
||||
@@ -317,9 +316,7 @@ _CONFIG_DEFINITIONS = {
|
||||
'XBMC_PASSWORD': (str, 'XBMC', ''),
|
||||
'XBMC_UPDATE': (int, 'XBMC', 0),
|
||||
'XBMC_USERNAME': (str, 'XBMC', ''),
|
||||
'XLDPROFILE': (str, 'General', ''),
|
||||
'BANDCAMP': (int, 'General', 0),
|
||||
'BANDCAMP_DIR': (path, 'General', '')
|
||||
'XLDPROFILE': (str, 'General', '')
|
||||
}
|
||||
|
||||
|
||||
@@ -331,7 +328,7 @@ class Config(object):
|
||||
def __init__(self, config_file):
|
||||
""" Initialize the config with values from a file """
|
||||
self._config_file = config_file
|
||||
self._config = ConfigParser(interpolation=None)
|
||||
self._config = ConfigParser()
|
||||
self._config.read(self._config_file)
|
||||
for key in list(_CONFIG_DEFINITIONS.keys()):
|
||||
self.check_setting(key)
|
||||
@@ -367,12 +364,12 @@ class Config(object):
|
||||
|
||||
try:
|
||||
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
|
||||
# a config upgrade, since a user can at any time put
|
||||
# some_key = 'some_val'
|
||||
if type(my_val) in [str, path]:
|
||||
my_val = my_val.strip('"').strip("'")
|
||||
# a config upgrade, since a user can at any time put
|
||||
# some_key = ''
|
||||
if my_val == '""' or my_val == "''":
|
||||
my_val = ''
|
||||
except Exception:
|
||||
my_val = default
|
||||
self._config[section][ini_key] = str(my_val)
|
||||
@@ -380,7 +377,7 @@ class Config(object):
|
||||
|
||||
def write(self):
|
||||
""" 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
|
||||
# correctly defined to keep from losing data
|
||||
@@ -411,7 +408,7 @@ class Config(object):
|
||||
""" Return the extra newznab tuples """
|
||||
extra_newznabs = list(
|
||||
zip(*[itertools.islice(self.EXTRA_NEWZNABS, i, None, 3)
|
||||
for i in range(3)])
|
||||
for i in range(3)])
|
||||
)
|
||||
return extra_newznabs
|
||||
|
||||
@@ -430,7 +427,7 @@ class Config(object):
|
||||
""" Return the extra torznab tuples """
|
||||
extra_torznabs = list(
|
||||
zip(*[itertools.islice(self.EXTRA_TORZNABS, i, None, 4)
|
||||
for i in range(4)])
|
||||
for i in range(4)])
|
||||
)
|
||||
return extra_torznabs
|
||||
|
||||
@@ -507,7 +504,7 @@ class Config(object):
|
||||
if self.EXTRA_TORZNABS:
|
||||
extra_torznabs = list(
|
||||
zip(*[itertools.islice(self.EXTRA_TORZNABS, i, None, 3)
|
||||
for i in range(3)])
|
||||
for i in range(3)])
|
||||
)
|
||||
new_torznabs = []
|
||||
for torznab in extra_torznabs:
|
||||
|
||||
+1
-2
@@ -18,7 +18,6 @@
|
||||
###################################
|
||||
|
||||
|
||||
|
||||
import time
|
||||
|
||||
import sqlite3
|
||||
@@ -117,7 +116,7 @@ class DBConnection:
|
||||
break
|
||||
|
||||
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
|
||||
if args is None:
|
||||
logger.debug('Database error: %s. Query: %s', e, query)
|
||||
|
||||
+94
-50
@@ -35,7 +35,6 @@
|
||||
# along with SickRage. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
|
||||
from headphones import logger
|
||||
|
||||
import time
|
||||
@@ -58,12 +57,12 @@ def _scrubber(text):
|
||||
if scrub_logs:
|
||||
try:
|
||||
# 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
|
||||
# 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
|
||||
text = re.sub(r"_session_id'\: '.*'", r"_session_id': 'REMOVED'", text)
|
||||
text = re.sub("_session_id'\: '.*'", "_session_id': 'REMOVED'", text)
|
||||
# Local Windows user path
|
||||
if text.lower().startswith('c:\\users\\'):
|
||||
k = text.split('\\')
|
||||
@@ -84,15 +83,19 @@ def addTorrent(link, data=None, name=None):
|
||||
result = {}
|
||||
retid = False
|
||||
url_orpheus = ['https://orpheus.network/', 'http://orpheus.network/']
|
||||
url_waffles = ['https://waffles.ch/', 'http://waffles.ch/']
|
||||
|
||||
if link.lower().startswith('magnet:'):
|
||||
logger.debug('Deluge: Got a magnet link: %s' % _scrubber(link))
|
||||
result = {'type': 'magnet',
|
||||
'url': link}
|
||||
'url': link}
|
||||
retid = _add_torrent_magnet(result)
|
||||
|
||||
elif link.lower().startswith('http://') or link.lower().startswith('https://'):
|
||||
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)):
|
||||
logger.debug('Deluge: Using different User-Agent for this site')
|
||||
user_agent = 'Headphones'
|
||||
@@ -124,9 +127,9 @@ def addTorrent(link, data=None, name=None):
|
||||
# Extract torrent name from .torrent
|
||||
try:
|
||||
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')
|
||||
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:
|
||||
logger.debug('Deluge: Could not get torrent name, getting file name')
|
||||
# get last part of link/path (name only)
|
||||
@@ -139,8 +142,8 @@ def addTorrent(link, data=None, name=None):
|
||||
except:
|
||||
logger.debug('Deluge: Sending Deluge torrent with problematic name and some content')
|
||||
result = {'type': 'torrent',
|
||||
'name': name,
|
||||
'content': torrentfile}
|
||||
'name': name,
|
||||
'content': torrentfile}
|
||||
retid = _add_torrent_file(result)
|
||||
|
||||
# elif link.endswith('.torrent') or data:
|
||||
@@ -156,9 +159,9 @@ def addTorrent(link, data=None, name=None):
|
||||
# Extract torrent name from .torrent
|
||||
try:
|
||||
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')
|
||||
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:
|
||||
logger.debug('Deluge: Could not get torrent name, getting file name')
|
||||
# get last part of link/path (name only)
|
||||
@@ -171,8 +174,8 @@ def addTorrent(link, data=None, name=None):
|
||||
except UnicodeDecodeError:
|
||||
logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name.decode('utf-8'), str(torrentfile)[:40]))
|
||||
result = {'type': 'torrent',
|
||||
'name': name,
|
||||
'content': torrentfile}
|
||||
'name': name,
|
||||
'content': torrentfile}
|
||||
retid = _add_torrent_file(result)
|
||||
|
||||
else:
|
||||
@@ -204,7 +207,7 @@ def getTorrentFolder(result):
|
||||
],
|
||||
"id": 21})
|
||||
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']
|
||||
|
||||
tries = 0
|
||||
@@ -212,7 +215,7 @@ def getTorrentFolder(result):
|
||||
tries += 1
|
||||
time.sleep(5)
|
||||
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']
|
||||
|
||||
post_data = json.dumps({"method": "web.get_torrent_status",
|
||||
@@ -231,7 +234,7 @@ def getTorrentFolder(result):
|
||||
"id": 23})
|
||||
|
||||
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['name'] = json.loads(response.text)['result']['name']
|
||||
@@ -260,7 +263,7 @@ def removeTorrent(torrentid, remove_data=False):
|
||||
"id": 26})
|
||||
|
||||
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:
|
||||
state = json.loads(response.text)['result']['state']
|
||||
@@ -279,10 +282,10 @@ def removeTorrent(torrentid, remove_data=False):
|
||||
"params": [
|
||||
torrentid,
|
||||
remove_data
|
||||
],
|
||||
],
|
||||
"id": 25})
|
||||
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']
|
||||
|
||||
return result
|
||||
@@ -325,12 +328,12 @@ def _get_auth():
|
||||
"id": 1})
|
||||
try:
|
||||
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:
|
||||
try:
|
||||
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,
|
||||
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
|
||||
logger.error('Deluge: Switching to HTTPS, but certificate won\'t be verified because NO CERTIFICATE WAS CONFIGURED!')
|
||||
delugeweb_url = delugeweb_url.replace('http:', 'https:')
|
||||
@@ -355,7 +358,7 @@ def _get_auth():
|
||||
"id": 10})
|
||||
try:
|
||||
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:
|
||||
logger.error('Deluge: Authentication failed: %s' % str(e))
|
||||
formatted_lines = traceback.format_exc().splitlines()
|
||||
@@ -372,7 +375,7 @@ def _get_auth():
|
||||
"id": 11})
|
||||
try:
|
||||
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:
|
||||
logger.error('Deluge: Authentication failed: %s' % str(e))
|
||||
formatted_lines = traceback.format_exc().splitlines()
|
||||
@@ -391,7 +394,7 @@ def _get_auth():
|
||||
|
||||
try:
|
||||
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:
|
||||
logger.error('Deluge: Authentication failed: %s' % str(e))
|
||||
formatted_lines = traceback.format_exc().splitlines()
|
||||
@@ -404,7 +407,7 @@ def _get_auth():
|
||||
|
||||
try:
|
||||
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:
|
||||
logger.error('Deluge: Authentication failed: %s' % str(e))
|
||||
formatted_lines = traceback.format_exc().splitlines()
|
||||
@@ -429,7 +432,7 @@ def _add_torrent_magnet(result):
|
||||
"params": [result['url'], {}],
|
||||
"id": 2})
|
||||
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']
|
||||
logger.debug('Deluge: Response was %s' % str(json.loads(response.text)))
|
||||
return json.loads(response.text)['result']
|
||||
@@ -449,7 +452,7 @@ def _add_torrent_url(result):
|
||||
"params": [result['url'], {}],
|
||||
"id": 32})
|
||||
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']
|
||||
logger.debug('Deluge: Response was %s' % str(json.loads(response.text)))
|
||||
return json.loads(response.text)['result']
|
||||
@@ -462,30 +465,13 @@ def _add_torrent_url(result):
|
||||
|
||||
def _add_torrent_file(result):
|
||||
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):
|
||||
_get_auth()
|
||||
try:
|
||||
# content is torrent file contents that needs to be encoded to base64
|
||||
post_data = json.dumps({"method": "core.add_torrent_file",
|
||||
"params": [result['name'] + '.torrent',
|
||||
b64encode(result['content']).decode(),
|
||||
options],
|
||||
b64encode(result['content']).decode(), {}],
|
||||
"id": 2})
|
||||
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
|
||||
verify=deluge_verify_cert, headers=headers)
|
||||
@@ -515,7 +501,7 @@ def setTorrentLabel(result):
|
||||
"params": [],
|
||||
"id": 3})
|
||||
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']
|
||||
|
||||
if labels is not None:
|
||||
@@ -526,7 +512,7 @@ def setTorrentLabel(result):
|
||||
"params": [label],
|
||||
"id": 4})
|
||||
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)
|
||||
except Exception as e:
|
||||
logger.error('Deluge: Setting label failed: %s' % str(e))
|
||||
@@ -538,7 +524,7 @@ def setTorrentLabel(result):
|
||||
"params": [result['hash'], label],
|
||||
"id": 5})
|
||||
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)
|
||||
else:
|
||||
logger.debug('Deluge: Label plugin not detected')
|
||||
@@ -562,12 +548,12 @@ def setSeedRatio(result):
|
||||
"params": [result['hash'], True],
|
||||
"id": 5})
|
||||
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",
|
||||
"params": [result['hash'], float(ratio)],
|
||||
"id": 6})
|
||||
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']
|
||||
|
||||
@@ -579,3 +565,61 @@ def setSeedRatio(result):
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os.path
|
||||
|
||||
import plistlib
|
||||
import biplist
|
||||
from headphones import logger
|
||||
|
||||
|
||||
@@ -14,9 +14,8 @@ def getXldProfile(xldProfile):
|
||||
|
||||
# Get xld preferences plist
|
||||
try:
|
||||
with open(expanded, 'rb') as _f:
|
||||
preferences = plistlib.load(_f)
|
||||
except Exception as e:
|
||||
preferences = biplist.readPlist(expanded)
|
||||
except (biplist.InvalidPlistException, biplist.NotBinaryPlistException) as e:
|
||||
logger.error("Error reading xld preferences plist: %s", e)
|
||||
return (xldProfileNotFound, None, None)
|
||||
|
||||
|
||||
+40
-58
@@ -14,25 +14,25 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import re
|
||||
from operator import itemgetter
|
||||
import unicodedata
|
||||
import datetime
|
||||
import shutil
|
||||
import time
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
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
|
||||
import glob
|
||||
|
||||
from beets import logging as beetslogging
|
||||
from mediafile import MediaFile, FileTypeError, UnreadableFileError
|
||||
from six import text_type
|
||||
from unidecode import unidecode
|
||||
import six
|
||||
from contextlib import contextmanager
|
||||
|
||||
import fnmatch
|
||||
import functools
|
||||
import re
|
||||
import os
|
||||
from mediafile import MediaFile, FileTypeError, UnreadableFileError
|
||||
from unidecode import unidecode
|
||||
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 = re.compile(r"^(CD|dics)\s*[0-9]+$", re.I)
|
||||
|
||||
|
||||
def cmp(x, y):
|
||||
"""
|
||||
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
|
||||
"""
|
||||
if x is None and y is None:
|
||||
return 0
|
||||
elif x is None:
|
||||
return -1
|
||||
elif y is None:
|
||||
return 1
|
||||
else:
|
||||
return (x > y) - (x < y)
|
||||
return (x > y) - (x < y)
|
||||
|
||||
|
||||
def multikeysort(items, columns):
|
||||
comparers = [
|
||||
@@ -74,7 +69,7 @@ def multikeysort(items, columns):
|
||||
else:
|
||||
return 0
|
||||
|
||||
return sorted(items, key=cmp_to_key(comparer))
|
||||
return sorted(items, key=functools.cmp_to_key(comparer))
|
||||
|
||||
|
||||
def checked(variable):
|
||||
@@ -156,25 +151,28 @@ def convert_seconds(s):
|
||||
|
||||
|
||||
def today():
|
||||
return date.isoformat(date.today())
|
||||
today = datetime.date.today()
|
||||
yyyymmdd = datetime.date.isoformat(today)
|
||||
return yyyymmdd
|
||||
|
||||
|
||||
def now():
|
||||
now = datetime.now()
|
||||
now = datetime.datetime.now()
|
||||
return now.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def is_valid_date(d):
|
||||
if not d:
|
||||
def get_age(date):
|
||||
try:
|
||||
split_date = date.split('-')
|
||||
except:
|
||||
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):
|
||||
'''Requires a valid date'''
|
||||
delta = date.today() - date.fromisoformat(d)
|
||||
return delta.days
|
||||
return days_old
|
||||
|
||||
|
||||
def bytes_to_mb(bytes):
|
||||
@@ -184,7 +182,7 @@ def bytes_to_mb(bytes):
|
||||
|
||||
|
||||
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:
|
||||
return int(float(result.group(1)) * 1048576)
|
||||
|
||||
@@ -236,7 +234,7 @@ def pattern_substitute(pattern, dic, normalize=False):
|
||||
j = unicodedata.normalize('NFC', j)
|
||||
except TypeError:
|
||||
j = unicodedata.normalize('NFC',
|
||||
j.decode(headphones.SYS_ENCODING, 'replace'))
|
||||
j.decode(headphones.SYS_ENCODING, 'replace'))
|
||||
new_dic[i] = j
|
||||
dic = new_dic
|
||||
return pathrender.render(pattern, dic)[0]
|
||||
@@ -253,9 +251,9 @@ def replace_all(text, dic):
|
||||
|
||||
def replace_illegal_chars(string, type="file"):
|
||||
if type == "file":
|
||||
string = re.sub(r"[\?\"*:|<>/]", "_", string)
|
||||
string = re.sub('[\?"*:|<>/]', '_', string)
|
||||
if type == "folder":
|
||||
string = re.sub(r"[:\?<>\"|*]", "_", string)
|
||||
string = re.sub('[:\?<>"|*]', '_', string)
|
||||
return string
|
||||
|
||||
|
||||
@@ -281,7 +279,7 @@ _XLATE_GRAPHICAL_AND_DIACRITICAL = {
|
||||
'Ǥ': 'G', 'ǥ': 'g', 'DZ': 'DZ', 'Dz': 'Dz', 'dz': 'dz',
|
||||
'Ȥ': 'Z', 'ȥ': 'z', '№': 'No.',
|
||||
'º': 'o.', # normalize Nº abbrev (popular w/ classical music),
|
||||
# this is 'masculine ordering indicator', not degree
|
||||
# this is 'masculine ordering indicator', not degree
|
||||
}
|
||||
|
||||
_XLATE_SPECIAL = {
|
||||
@@ -386,7 +384,7 @@ def clean_musicbrainz_name(s, return_as_string=True):
|
||||
|
||||
|
||||
def cleanTitle(title):
|
||||
title = re.sub(r"[\.\-\/\_]", " ", title).lower()
|
||||
title = re.sub('[\.\-\/\_]', ' ', title).lower()
|
||||
|
||||
# Strip out extra whitespace
|
||||
title = ' '.join(title.split())
|
||||
@@ -506,7 +504,7 @@ def path_match_patterns(path, patterns):
|
||||
"""
|
||||
|
||||
for pattern in patterns:
|
||||
if fnmatch(path, pattern):
|
||||
if fnmatch.fnmatch(path, pattern):
|
||||
return True
|
||||
|
||||
# No match
|
||||
@@ -712,7 +710,7 @@ def preserve_torrent_directory(albumpath, forced=False, single=False):
|
||||
workdir = os.path.join(tempdir, prefix)
|
||||
workdir = re.sub(r'\[', '[[]', workdir)
|
||||
workdir = re.sub(r'(?<!\[)\]', '[]]', workdir)
|
||||
if len(glob(workdir + '*/')) >= 3:
|
||||
if len(glob.glob(workdir + '*/')) >= 3:
|
||||
logger.error(
|
||||
"Looks like a temp directory has previously been created "
|
||||
"for this albumpath, not continuing "
|
||||
@@ -860,8 +858,6 @@ def smartMove(src, dest, delete=True):
|
||||
try:
|
||||
os.rename(src, os.path.join(source_dir, newfile))
|
||||
filename = newfile
|
||||
source_path = os.path.join(source_dir, filename)
|
||||
dest_path = os.path.join(dest, filename)
|
||||
except Exception as e:
|
||||
logger.warn(f"Error renaming {src}: {e}")
|
||||
break
|
||||
@@ -888,7 +884,7 @@ def smartMove(src, dest, delete=True):
|
||||
shutil.copy(source_path, dest_path)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warn(f"Error copying {filename}: {e}")
|
||||
logger.warn(f"Error copying {filename}: {e}")
|
||||
|
||||
|
||||
def walk_directory(basedir, followlinks=True):
|
||||
@@ -1033,7 +1029,7 @@ class BeetsLogCapture(beetslogging.Handler):
|
||||
self.messages = []
|
||||
|
||||
def emit(self, record):
|
||||
self.messages.append(text_type(record.msg))
|
||||
self.messages.append(six.text_type(record.msg))
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -1045,17 +1041,3 @@ def capture_beets_log(logger='beets'):
|
||||
yield capture.messages
|
||||
finally:
|
||||
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)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
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):
|
||||
@@ -14,9 +14,9 @@ class HelpersTest(TestCase):
|
||||
'Symphonęy Nº9': 'Symphoney No.9',
|
||||
'ÆæßðÞIJij': 'AeaessdThIJıj',
|
||||
'Obsessió (Cerebral Apoplexy remix)': 'obsessio cerebral '
|
||||
'apoplexy remix',
|
||||
'apoplexy remix',
|
||||
'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',
|
||||
'Björk Guðmundsdóttir': 'bjork gudmundsdottir',
|
||||
'L\'Arc~en~Ciel': 'larc en ciel',
|
||||
@@ -46,28 +46,3 @@ class HelpersTest(TestCase):
|
||||
self.assertEqual(
|
||||
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
@@ -39,7 +39,7 @@ def is_exists(artistid):
|
||||
|
||||
if any(artistid in x for x in artistlist):
|
||||
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
|
||||
else:
|
||||
return False
|
||||
@@ -102,7 +102,12 @@ def artistlist_to_mbids(artistlist, forced=False):
|
||||
myDB.action('DELETE from newartists WHERE ArtistName=?', [artist])
|
||||
|
||||
# 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):
|
||||
@@ -240,7 +245,7 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
|
||||
rgid = rg['id']
|
||||
skip_log = 0
|
||||
# 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()
|
||||
|
||||
@@ -269,18 +274,18 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
|
||||
if len(check_release_date) == 10:
|
||||
release_date = check_release_date
|
||||
elif len(check_release_date) == 7:
|
||||
release_date = check_release_date + "-27"
|
||||
release_date = check_release_date + "-31"
|
||||
elif len(check_release_date) == 4:
|
||||
release_date = check_release_date + "-12-27"
|
||||
release_date = check_release_date + "-12-31"
|
||||
else:
|
||||
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)",
|
||||
artist['artist_name'], rg['title'], ignore_age)
|
||||
artist['artist_name'], rg['title'], pause_delta)
|
||||
new_releases = mb.get_new_releases(rgid, includeExtras, True)
|
||||
else:
|
||||
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
|
||||
new_releases = 0
|
||||
|
||||
@@ -445,9 +450,14 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
|
||||
|
||||
if headphones.CONFIG.AUTOWANT_ALL:
|
||||
newValueDict['Status'] = "Wanted"
|
||||
elif headphones.CONFIG.AUTOWANT_UPCOMING:
|
||||
if helpers.is_valid_date(album['ReleaseDate']) and helpers.age(album['ReleaseDate']) < 21:
|
||||
newValueDict['Status'] = "Wanted"
|
||||
elif album['ReleaseDate'] > today and headphones.CONFIG.AUTOWANT_UPCOMING:
|
||||
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:
|
||||
newValueDict['Status'] = "Skipped"
|
||||
|
||||
|
||||
+19
-26
@@ -23,7 +23,7 @@ from headphones import db, logger, request
|
||||
TIMEOUT = 60.0 # seconds
|
||||
REQUEST_LIMIT = 1.0 / 5 # seconds
|
||||
ENTRY_POINT = "https://ws.audioscrobbler.com/2.0/"
|
||||
APP_API_KEY = "395e6ec6bb557382fc41fde867bce66f"
|
||||
API_KEY = "395e6ec6bb557382fc41fde867bce66f"
|
||||
|
||||
# Required for API 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):
|
||||
"""
|
||||
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.
|
||||
|
||||
By default, this method will request the JSON format, since it is more
|
||||
@@ -40,42 +40,35 @@ def request_lastfm(method, **kwargs):
|
||||
|
||||
# Prepare request
|
||||
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")
|
||||
|
||||
# Send request
|
||||
logger.debug("Calling Last.fm method: %s", method)
|
||||
logger.debug("Last.fm call parameters: %s", kwargs)
|
||||
logger.debug("Calling Last.FM method: %s", method)
|
||||
logger.debug("Last.FM call parameters: %s", kwargs)
|
||||
|
||||
data = request.request_json(ENTRY_POINT, timeout=TIMEOUT, params=kwargs, lock=lastfm_lock)
|
||||
|
||||
# Parse response and check for errors.
|
||||
if not data:
|
||||
logger.error("Error calling Last.fm method: %s", method)
|
||||
logger.error("Error calling Last.FM method: %s", method)
|
||||
return
|
||||
|
||||
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 data
|
||||
|
||||
|
||||
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()
|
||||
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 = []
|
||||
|
||||
for result in results:
|
||||
for result in results[:12]:
|
||||
data = request_lastfm("artist.getsimilar", mbid=result["ArtistId"])
|
||||
|
||||
if data and "similarartists" in data:
|
||||
@@ -92,7 +85,7 @@ def getSimilar():
|
||||
artistlist.append((artist_name, artist_mbid))
|
||||
|
||||
# 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)
|
||||
|
||||
for artist, mbid in artistlist:
|
||||
@@ -110,7 +103,7 @@ def getSimilar():
|
||||
|
||||
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():
|
||||
@@ -118,16 +111,16 @@ def getArtists():
|
||||
results = myDB.select("SELECT ArtistID from artists")
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
if data and "artists" in data:
|
||||
artistlist = []
|
||||
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:
|
||||
artist_mbid = artist["mbid"]
|
||||
@@ -140,20 +133,20 @@ def getArtists():
|
||||
for artistid in artistlist:
|
||||
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):
|
||||
myDB = db.DBConnection()
|
||||
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)
|
||||
|
||||
if data and "topartists" in data:
|
||||
artistlist = []
|
||||
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:
|
||||
try:
|
||||
@@ -169,4 +162,4 @@ def getTagTopArtists(tag, limit=50):
|
||||
for artistid in artistlist:
|
||||
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
@@ -77,9 +77,9 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
|
||||
if track['ArtistName']:
|
||||
# Make sure deleted files get accounted for when updating artist track counts
|
||||
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(
|
||||
f"{track['Location']} removed from Headphones, as it "
|
||||
f"{Track['Location']} removed from Headphones, as it "
|
||||
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)
|
||||
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.
|
||||
if not check_exist_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']:
|
||||
new_artists.append(f_artist)
|
||||
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)
|
||||
else:
|
||||
continue
|
||||
@@ -191,26 +191,23 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
|
||||
# Now we start track matching
|
||||
logger.info(f"{new_track_count} new/modified tracks found and added to the database")
|
||||
dbtracks = myDB.action(
|
||||
"SELECT * FROM have WHERE Matched IS NULL AND LOCATION LIKE ?",
|
||||
[f"{dir}%"]
|
||||
)
|
||||
"SELECT * FROM have WHERE Matched IS NULL AND LOCATION LIKE ?",
|
||||
[f"{dir}%"]
|
||||
)
|
||||
dbtracks_count = myDB.action(
|
||||
"SELECT COUNT(*) FROM have WHERE Matched IS NULL AND LOCATION LIKE ?",
|
||||
[f"{dir}%"]
|
||||
).fetchone()[0]
|
||||
"SELECT COUNT(*) FROM have WHERE Matched IS NULL AND LOCATION LIKE ?",
|
||||
[f"{dir}%"]
|
||||
).fetchone()[0]
|
||||
logger.info(f"Found {dbtracks_count} new/modified tracks in `{dir}`")
|
||||
logger.info("Matching tracks to the appropriate releases....")
|
||||
|
||||
|
||||
|
||||
# Sort the track_list by most vague (e.g. no trackid or 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
|
||||
|
||||
sorted_dbtracks = helpers.multikeysort(dbtracks, ['ArtistName', 'AlbumTitle'])
|
||||
|
||||
|
||||
# We'll use this to give a % completion, just because the
|
||||
# track matching might take a while
|
||||
tracks_completed = 0
|
||||
@@ -227,8 +224,8 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
|
||||
|
||||
tracks_completed += 1
|
||||
completion_percentage = math.floor(
|
||||
float(tracks_completed) / dbtracks_count * 1000
|
||||
) / 10
|
||||
float(tracks_completed) / dbtracks_count * 1000
|
||||
) / 10
|
||||
|
||||
if completion_percentage >= (last_completion_percentage + 10):
|
||||
logger.info("Track matching is " + str(completion_percentage) + "% complete")
|
||||
|
||||
+13
-7
@@ -14,14 +14,20 @@
|
||||
# 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
|
||||
|
||||
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)
|
||||
|
||||
@@ -91,7 +97,7 @@ def findArtist(name, limit=1):
|
||||
try:
|
||||
artistResults = musicbrainzngs.search_artists(limit=limit, **criteria)['artist-list']
|
||||
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(
|
||||
"Tried to search without a term, or an empty one. Provided artist (probably emtpy): %s",
|
||||
name)
|
||||
|
||||
+3
-10
@@ -38,6 +38,7 @@ class MetadataDict(dict):
|
||||
lowercase) in member variable self._lower. If case-sensitive lookup
|
||||
fails, another case-insensitive attempt is made.
|
||||
"""
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
super(MetadataDict, self).__setitem__(key, value)
|
||||
self._lower.__setitem__(key.lower(), value)
|
||||
@@ -79,7 +80,6 @@ class Vars:
|
||||
Metadata $variable names (only ones set explicitly by headphones).
|
||||
"""
|
||||
DISC = '$Disc'
|
||||
DISC_TOTAL = '$DiscTotal'
|
||||
TRACK = '$Track'
|
||||
TITLE = '$Title'
|
||||
ARTIST = '$Artist'
|
||||
@@ -172,7 +172,7 @@ def _lower(s):
|
||||
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]
|
||||
"""
|
||||
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)
|
||||
|
||||
date, year = _date_year(release)
|
||||
|
||||
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):
|
||||
if not f.disc:
|
||||
disc_number = ''
|
||||
else:
|
||||
disc_number = '%d' % f.disc
|
||||
@@ -233,7 +227,6 @@ def file_metadata(path, release, single_disc_ignore=False):
|
||||
album_title = release['AlbumTitle']
|
||||
override_values = {
|
||||
Vars.DISC: disc_number,
|
||||
Vars.DISC_TOTAL: disc_total,
|
||||
Vars.TRACK: track_number,
|
||||
Vars.TITLE: title,
|
||||
Vars.ARTIST: artist_name,
|
||||
|
||||
@@ -30,6 +30,7 @@ from . import getXldProfile
|
||||
|
||||
|
||||
def encode(albumPath):
|
||||
print(albumPath)
|
||||
use_xld = headphones.CONFIG.ENCODER == 'xld'
|
||||
|
||||
# Return if xld details not found
|
||||
|
||||
+19
-15
@@ -1,5 +1,7 @@
|
||||
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 json
|
||||
from email.mime.text import MIMEText
|
||||
@@ -7,7 +9,9 @@ import smtplib
|
||||
import email.utils
|
||||
from http.client import HTTPSConnection
|
||||
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 os.path
|
||||
@@ -17,7 +21,7 @@ import cherrypy
|
||||
import headphones
|
||||
import gntp.notifier
|
||||
#import oauth2 as oauth
|
||||
import twitter
|
||||
import twitter
|
||||
|
||||
|
||||
class GROWL(object):
|
||||
@@ -246,7 +250,7 @@ class XBMC(object):
|
||||
|
||||
if version < 12: # Eden
|
||||
notification = header + "," + message + "," + time + \
|
||||
"," + albumartpath
|
||||
"," + albumartpath
|
||||
notifycommand = {'command': 'ExecBuiltIn',
|
||||
'parameter': 'Notification(' +
|
||||
notification + ')'}
|
||||
@@ -440,7 +444,7 @@ class Plex(object):
|
||||
|
||||
if version < 12: # Eden
|
||||
notification = header + "," + message + "," + time + \
|
||||
"," + albumartpath
|
||||
"," + albumartpath
|
||||
notifycommand = {'command': 'ExecBuiltIn',
|
||||
'parameter': 'Notification(' +
|
||||
notification + ')'}
|
||||
@@ -604,12 +608,12 @@ class JOIN(object):
|
||||
self.url += '&deviceId={deviceid}'
|
||||
|
||||
response = urllib.request.urlopen(self.url.format(apikey=self.apikey,
|
||||
title=quote_plus(event),
|
||||
text=quote_plus(
|
||||
message.encode(
|
||||
"utf-8")),
|
||||
icon=icon,
|
||||
deviceid=self.deviceid))
|
||||
title=quote_plus(event),
|
||||
text=quote_plus(
|
||||
message.encode(
|
||||
"utf-8")),
|
||||
icon=icon,
|
||||
deviceid=self.deviceid))
|
||||
|
||||
if response:
|
||||
logger.info("Join notifications sent.")
|
||||
@@ -733,8 +737,8 @@ class TwitterNotifier(object):
|
||||
def notify_download(self, title):
|
||||
if headphones.CONFIG.TWITTER_ENABLED:
|
||||
self._notifyTwitter(common.notifyStrings[
|
||||
common.NOTIFY_DOWNLOAD] + ': ' +
|
||||
title + ' at ' + helpers.now())
|
||||
common.NOTIFY_DOWNLOAD] + ': ' +
|
||||
title + ' at ' + helpers.now())
|
||||
|
||||
def test_notify(self):
|
||||
return self._notifyTwitter(
|
||||
@@ -798,7 +802,7 @@ class TwitterNotifier(object):
|
||||
if resp['status'] != '200':
|
||||
logger.info('The request for a token with did not succeed: ' + str(
|
||||
resp['status']),
|
||||
logger.ERROR)
|
||||
logger.ERROR)
|
||||
return False
|
||||
else:
|
||||
logger.info('Your Twitter Access Token key: %s' % access_token[
|
||||
@@ -1020,7 +1024,7 @@ class TELEGRAM(object):
|
||||
# MusicBrainz link
|
||||
if rgid:
|
||||
message += '\n\n <a href="https://musicbrainz.org/' \
|
||||
'release-group/%s">MusicBrainz</a>' % rgid
|
||||
'release-group/%s">MusicBrainz</a>' % rgid
|
||||
|
||||
# Send image
|
||||
response = None
|
||||
|
||||
@@ -70,8 +70,7 @@ def sendNZB(nzb):
|
||||
nzbcontent64 = None
|
||||
if nzb.resultType == "nzbdata":
|
||||
data = nzb.extraInfo[0]
|
||||
# NZBGet needs a string, not bytes
|
||||
nzbcontent64 = standard_b64encode(data).decode("utf-8")
|
||||
nzbcontent64 = standard_b64encode(data)
|
||||
|
||||
logger.info("Sending NZB to NZBget")
|
||||
logger.debug("URL: " + url)
|
||||
|
||||
@@ -38,6 +38,7 @@ __author__ = "Andrzej Ciarkowski <andrzej.ciarkowski@gmail.com>"
|
||||
|
||||
class _PatternElement(object):
|
||||
'''ABC for hierarchy of path name renderer pattern elements.'''
|
||||
|
||||
def render(self, replacement):
|
||||
# type: (Mapping[str,str]) -> str
|
||||
'''Format this _PatternElement into string using provided substitution dictionary.'''
|
||||
@@ -55,6 +56,7 @@ class _Generator(_PatternElement):
|
||||
|
||||
class _Replacement(_Generator):
|
||||
'''Replacement variable, eg. $title.'''
|
||||
|
||||
def __init__(self, pattern):
|
||||
# type: (str)
|
||||
self._pattern = pattern
|
||||
@@ -81,6 +83,7 @@ class _Replacement(_Generator):
|
||||
|
||||
class _LiteralText(_PatternElement):
|
||||
'''Just a plain piece of text to be rendered "as is".'''
|
||||
|
||||
def __init__(self, text):
|
||||
# type: (str)
|
||||
self._text = text
|
||||
|
||||
+22
-55
@@ -27,7 +27,7 @@ from beets import config as beetsconfig
|
||||
from beets import logging as beetslogging
|
||||
from mediafile import MediaFile, FileTypeError, UnreadableFileError
|
||||
from beetsplug import lyrics as beetslyrics
|
||||
from headphones import notifiers, utorrent, transmission, deluge, qbittorrent, soulseek
|
||||
from headphones import notifiers, utorrent, transmission, deluge, qbittorrent
|
||||
from headphones import db, albumart, librarysync
|
||||
from headphones import logger, helpers, mb, music_encoder
|
||||
from headphones import metadata
|
||||
@@ -36,45 +36,18 @@ postprocessor_lock = threading.Lock()
|
||||
|
||||
|
||||
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:
|
||||
myDB = db.DBConnection()
|
||||
snatched = myDB.select('SELECT * from snatched WHERE Status="Snatched"')
|
||||
|
||||
for album in snatched:
|
||||
if album['FolderName']:
|
||||
folder_name = album['FolderName']
|
||||
single = False
|
||||
|
||||
# Soulseek, check download complete or errored
|
||||
if album['Kind'] == 'soulseek':
|
||||
match = re.search(r'\{(.*?)\}(.*?)$', folder_name) # get soulseek user from folder_name
|
||||
user_name = match.group(1)
|
||||
folder_name = match.group(2)
|
||||
completed, errored = soulseek.download_completed_album(user_name, folder_name)
|
||||
if errored:
|
||||
# If the album had any tracks with errors in it, the whole download is considered faulty. Status will be reset to wanted.
|
||||
logger.info(f"Soulseek: Album with folder '{folder_name}' had errors during download. Setting status to 'Wanted'.")
|
||||
myDB.action('UPDATE albums SET Status="Wanted" WHERE AlbumID=? AND Status="Snatched"', (album['AlbumID'],))
|
||||
myDB.action('UPDATE snatched SET status = "Unprocessed" WHERE AlbumID=?', (album['AlbumID'],))
|
||||
|
||||
# Folder will be removed from configured complete and Incomplete directory
|
||||
complete_path = os.path.join(headphones.CONFIG.SOULSEEK_DOWNLOAD_DIR, folder_name)
|
||||
incomplete_path = os.path.join(headphones.CONFIG.SOULSEEK_INCOMPLETE_DOWNLOAD_DIR, folder_name)
|
||||
for path in [complete_path, incomplete_path]:
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
except Exception as e:
|
||||
pass
|
||||
continue
|
||||
elif completed:
|
||||
download_dir = headphones.CONFIG.SOULSEEK_DOWNLOAD_DIR
|
||||
else:
|
||||
continue
|
||||
elif album['Kind'] == 'nzb':
|
||||
download_dir = headphones.CONFIG.DOWNLOAD_DIR
|
||||
elif album['Kind'] == 'bandcamp':
|
||||
download_dir = headphones.CONFIG.BANDCAMP_DIR
|
||||
if album['Kind'] == 'nzb':
|
||||
download_dir = headphones.CONFIG.DOWNLOAD_DIR
|
||||
else:
|
||||
if headphones.CONFIG.DELUGE_DONE_DIRECTORY and headphones.CONFIG.TORRENT_DOWNLOADER == 3:
|
||||
download_dir = headphones.CONFIG.DELUGE_DONE_DIRECTORY
|
||||
@@ -92,6 +65,7 @@ def checkFolder():
|
||||
folder_name = torrent_folder_name
|
||||
|
||||
if folder_name:
|
||||
print(folder_name)
|
||||
album_path = os.path.join(download_dir, folder_name)
|
||||
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):
|
||||
print(albumpath)
|
||||
myDB = db.DBConnection()
|
||||
release = myDB.action('SELECT * from albums WHERE AlbumID=?', [albumid]).fetchone()
|
||||
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...')
|
||||
for downloaded_track in downloaded_track_list:
|
||||
track_name = os.path.splitext(downloaded_track)[0]
|
||||
split_track_name = re.sub(r'[\.\-\_]', r' ', track_name).lower()
|
||||
split_track_name = re.sub('[\.\-\_]', ' ', track_name).lower()
|
||||
for track in tracks:
|
||||
|
||||
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")
|
||||
markAsUnprocessed(albumid, albumpath, keep_original_folder)
|
||||
|
||||
|
||||
def markAsUnprocessed(albumid, albumpath, keep_original_folder=False):
|
||||
myDB = db.DBConnection()
|
||||
myDB.action(
|
||||
@@ -444,7 +420,7 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
|
||||
logger.debug("Write check exact error: %s", e)
|
||||
logger.error(
|
||||
f"`{downloaded_track}` is not writable. This is required "
|
||||
"for some post processing steps. Not continuing."
|
||||
"for some post processing steps. Not continuing."
|
||||
)
|
||||
if 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("Sending Twitter notification")
|
||||
#twitter = notifiers.TwitterNotifier()
|
||||
#twitter.notify_download(pushmessage)
|
||||
# twitter.notify_download(pushmessage)
|
||||
|
||||
if headphones.CONFIG.OSX_NOTIFY_ENABLED:
|
||||
from headphones import cache
|
||||
@@ -811,7 +787,7 @@ def moveFiles(albumpath, release, metadata_dict):
|
||||
newfolder = temp_folder + '[%i]' % i
|
||||
lossless_destination_path = os.path.normpath(
|
||||
os.path.join(
|
||||
headphones.CONFIG.LOSSLESS_DESTINATION_DIR,
|
||||
headphones.CONFIG.LOSSLESS_DESTINATION_DIR,
|
||||
newfolder
|
||||
)
|
||||
)
|
||||
@@ -853,7 +829,7 @@ def moveFiles(albumpath, release, metadata_dict):
|
||||
newfolder = temp_folder + '[%i]' % i
|
||||
lossy_destination_path = os.path.normpath(
|
||||
os.path.join(
|
||||
headphones.CONFIG.DESTINATION_DIR,
|
||||
headphones.CONFIG.DESTINATION_DIR,
|
||||
newfolder
|
||||
)
|
||||
)
|
||||
@@ -902,7 +878,7 @@ def moveFiles(albumpath, release, metadata_dict):
|
||||
os.remove(file_to_move)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error deleting `{file_to_move}` from source directory")
|
||||
f"Error deleting `{file_to_move}` from source directory")
|
||||
else:
|
||||
logger.error(
|
||||
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
|
||||
|
||||
for downloaded_track in downloaded_track_list:
|
||||
md, from_metadata = metadata.file_metadata(
|
||||
downloaded_track,
|
||||
release,
|
||||
headphones.CONFIG.RENAME_SINGLE_DISC_IGNORE
|
||||
)
|
||||
md, from_metadata = metadata.file_metadata(downloaded_track, release)
|
||||
if md is None:
|
||||
# unable to parse media file, skip file
|
||||
continue
|
||||
@@ -1164,6 +1136,7 @@ def updateFilePermissions(albumpaths):
|
||||
logger.error(f"Could not change permissions for `{full_path}`")
|
||||
continue
|
||||
|
||||
|
||||
def renameUnprocessedFolder(path, tag):
|
||||
"""
|
||||
Rename a unprocessed folder to a new unique name to indicate a certain
|
||||
@@ -1195,15 +1168,10 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
|
||||
|
||||
if dir:
|
||||
download_dirs.append(dir)
|
||||
else:
|
||||
if headphones.CONFIG.DOWNLOAD_DIR:
|
||||
download_dirs.append(headphones.CONFIG.DOWNLOAD_DIR)
|
||||
if headphones.CONFIG.SOULSEEK_DOWNLOAD_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 headphones.CONFIG.DOWNLOAD_DIR and not dir:
|
||||
download_dirs.append(headphones.CONFIG.DOWNLOAD_DIR)
|
||||
if headphones.CONFIG.DOWNLOAD_TORRENT_DIR and not dir:
|
||||
download_dirs.append(headphones.CONFIG.DOWNLOAD_TORRENT_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.
|
||||
download_dirs = list(set(download_dirs))
|
||||
@@ -1213,7 +1181,6 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
|
||||
folders = []
|
||||
|
||||
for download_dir in download_dirs:
|
||||
download_dir = download_dir.encode(headphones.SYS_ENCODING, 'replace')
|
||||
if not os.path.isdir(download_dir):
|
||||
logger.warn('Directory %s does not exist. Skipping', download_dir)
|
||||
continue
|
||||
@@ -1231,9 +1198,9 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
|
||||
subfolders = helpers.expand_subfolders(path_to_folder)
|
||||
|
||||
if expand_subfolders and subfolders is not None:
|
||||
folders.extend(subfolders.decode(headphones.SYS_ENCODING, 'replace'))
|
||||
folders.extend(subfolders)
|
||||
else:
|
||||
folders.append(path_to_folder.decode(headphones.SYS_ENCODING, 'replace'))
|
||||
folders.append(path_to_folder)
|
||||
|
||||
# Log number of folders
|
||||
if folders:
|
||||
|
||||
@@ -13,8 +13,12 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import urllib.request, urllib.parse, urllib.error
|
||||
import urllib.request, urllib.error, urllib.parse
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import http.cookiejar
|
||||
import json
|
||||
import time
|
||||
@@ -81,7 +85,7 @@ class qbittorrentclient(object):
|
||||
logger.debug('Error getting SID. qBittorrent responded with error: ' + str(err.reason))
|
||||
return
|
||||
for cookie in self.cookiejar:
|
||||
logger.debug('login cookie: ' + cookie.name + ', value: ' + cookie.value)
|
||||
logger.debug('login cookie: ' + cookie.name + ', value: ' + cookie.value)
|
||||
return
|
||||
|
||||
def _command(self, command, args=None, content_type=None, files=None):
|
||||
|
||||
@@ -23,9 +23,6 @@ from headphones import logger
|
||||
import feedparser
|
||||
import headphones
|
||||
import headphones.lock
|
||||
from bs4.builder import XMLParsedAsHTMLWarning
|
||||
import warnings
|
||||
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
|
||||
|
||||
|
||||
# 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
|
||||
if response.headers.get("content-type") and \
|
||||
"text/html" in response.headers.get("content-type"):
|
||||
"text/html" in response.headers.get("content-type"):
|
||||
try:
|
||||
soup = BeautifulSoup(response.content, "html.parser")
|
||||
except Exception:
|
||||
@@ -248,9 +245,7 @@ def server_message(response):
|
||||
|
||||
if message:
|
||||
# Truncate message if it is too long.
|
||||
if len(message) > 200:
|
||||
if not type(message) == str:
|
||||
message = message.decode(headphones.SYS_ENCODING, 'replace')
|
||||
message = message[:200] + "..."
|
||||
if len(message) > 150:
|
||||
message = message[:150] + "..."
|
||||
|
||||
logger.debug("Server responded with message: %s", message)
|
||||
|
||||
+9
-17
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import urllib.request, urllib.parse, urllib.error
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
import re
|
||||
@@ -11,7 +13,6 @@ from bs4 import BeautifulSoup
|
||||
|
||||
import headphones
|
||||
from headphones import logger
|
||||
from headphones.types import Result
|
||||
|
||||
|
||||
class Rutracker(object):
|
||||
@@ -42,22 +43,19 @@ class Rutracker(object):
|
||||
'login_password': headphones.CONFIG.RUTRACKER_PASSWORD,
|
||||
'login': b'\xc2\xf5\xee\xe4' # '%C2%F5%EE%E4'
|
||||
}
|
||||
headers = {
|
||||
'User-Agent' : 'Headphones'
|
||||
}
|
||||
|
||||
logger.info("Attempting to log in to rutracker...")
|
||||
|
||||
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
|
||||
if not self.has_bb_session_cookie(r):
|
||||
time.sleep(10)
|
||||
if headphones.CONFIG.RUTRACKER_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:
|
||||
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):
|
||||
self.loggedin = True
|
||||
logger.info("Successfully logged in to rutracker")
|
||||
@@ -116,10 +114,7 @@ class Rutracker(object):
|
||||
Parse the search results and return valid torrent list
|
||||
"""
|
||||
try:
|
||||
headers = {
|
||||
'Referer': self.search_referer,
|
||||
'User-Agent' : 'Headphones'
|
||||
}
|
||||
headers = {'Referer': self.search_referer}
|
||||
r = self.session.get(url=searchurl, headers=headers, timeout=self.timeout)
|
||||
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('&')])[
|
||||
't']
|
||||
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:
|
||||
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)))
|
||||
@@ -189,10 +184,7 @@ class Rutracker(object):
|
||||
downloadurl = 'https://rutracker.org/forum/dl.php?t=' + torrent_id
|
||||
cookie = {'bb_dl': torrent_id}
|
||||
try:
|
||||
headers = {
|
||||
'Referer': url,
|
||||
'User-Agent' : 'Headphones'
|
||||
}
|
||||
headers = {'Referer': url}
|
||||
r = self.session.post(url=downloadurl, cookies=cookie, headers=headers,
|
||||
timeout=self.timeout)
|
||||
return r.content
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ def sab_api_call(request_type=None, params={}, **kwargs):
|
||||
|
||||
if headphones.CONFIG.SAB_HOST.endswith('/'):
|
||||
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?"
|
||||
|
||||
|
||||
+603
-674
File diff suppressed because it is too large
Load Diff
@@ -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')
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
import time
|
||||
import json
|
||||
from base64 import b64encode
|
||||
import base64
|
||||
import urllib.parse
|
||||
import os
|
||||
|
||||
@@ -36,10 +36,10 @@ def addTorrent(link, data=None):
|
||||
|
||||
if link.endswith('.torrent') and not link.startswith(('http', 'magnet')) or data:
|
||||
if data:
|
||||
metainfo = b64encode(data).decode("utf-8")
|
||||
metainfo = str(base64.b64encode(data))
|
||||
else:
|
||||
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}
|
||||
else:
|
||||
arguments = {'filename': link, 'download-dir': headphones.CONFIG.DOWNLOAD_TORRENT_DIR}
|
||||
@@ -183,15 +183,15 @@ def torrentAction(method, arguments):
|
||||
if _session_id is not None:
|
||||
headers = {'x-transmission-session-id': _session_id}
|
||||
response = request.request_response(host, method="POST",
|
||||
data=data_json, headers=headers, auth=auth,
|
||||
whitelist_status_code=[200, 401, 409])
|
||||
data=data_json, headers=headers, auth=auth,
|
||||
whitelist_status_code=[200, 401, 409])
|
||||
else:
|
||||
response = request.request_response(host, auth=auth,
|
||||
whitelist_status_code=[401, 409])
|
||||
whitelist_status_code=[401, 409])
|
||||
if response.status_code == 401:
|
||||
if auth:
|
||||
logger.error("Username and/or password not accepted by "
|
||||
"Transmission")
|
||||
"Transmission")
|
||||
else:
|
||||
logger.error("Transmission authorization required")
|
||||
return
|
||||
@@ -205,4 +205,5 @@ def torrentAction(method, arguments):
|
||||
continue
|
||||
|
||||
resp_json = response.json()
|
||||
print(resp_json)
|
||||
return resp_json
|
||||
|
||||
@@ -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
|
||||
@@ -13,11 +13,15 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# 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 time
|
||||
from collections import namedtuple
|
||||
import urllib.request, urllib.error, urllib.parse
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.parse
|
||||
import http.cookiejar
|
||||
|
||||
|
||||
+81
-78
@@ -15,46 +15,38 @@
|
||||
|
||||
# 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 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
|
||||
from mako import exceptions
|
||||
import os
|
||||
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 import exceptions
|
||||
import headphones
|
||||
from headphones import (
|
||||
crier,
|
||||
db,
|
||||
importer,
|
||||
lastfm,
|
||||
librarysync,
|
||||
logger,
|
||||
mb,
|
||||
notifiers,
|
||||
searcher,
|
||||
)
|
||||
from headphones.helpers import (
|
||||
checked,
|
||||
clean_name,
|
||||
have_pct_have_total,
|
||||
pattern_substitute,
|
||||
radio,
|
||||
replace_illegal_chars,
|
||||
today,
|
||||
)
|
||||
from headphones.types import Result
|
||||
import cherrypy
|
||||
|
||||
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
|
||||
|
||||
|
||||
def serve_template(templatename, **kwargs):
|
||||
@@ -338,9 +330,9 @@ class WebInterface(object):
|
||||
'$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('/.', '/_')
|
||||
|
||||
if folder.endswith('.'):
|
||||
@@ -427,9 +419,9 @@ class WebInterface(object):
|
||||
myDB = db.DBConnection()
|
||||
for artist in args:
|
||||
myDB.action('DELETE FROM newartists WHERE ArtistName=?',
|
||||
[artist])
|
||||
[artist.decode(headphones.SYS_ENCODING, 'replace')])
|
||||
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)
|
||||
raise cherrypy.HTTPRedirect("home")
|
||||
|
||||
@@ -452,27 +444,40 @@ class WebInterface(object):
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def choose_specific_download(self, AlbumID):
|
||||
results = searcher.searchforalbum(AlbumID, choose_specific_download=True) or []
|
||||
return list(map(asdict, results))
|
||||
results = searcher.searchforalbum(AlbumID, choose_specific_download=True)
|
||||
|
||||
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.tools.json_out()
|
||||
def download_specific_release(self, AlbumID, title, size, url, provider, kind, **kwargs):
|
||||
# Handle situations where the torrent url contains arguments that are parsed
|
||||
if kwargs:
|
||||
url = parse.quote(url, safe=":?/=&") + '&' + parse.urlencode(kwargs)
|
||||
url = urllib.parse.quote(url, safe=":?/=&") + '&' + urllib.parse.urlencode(kwargs)
|
||||
try:
|
||||
result = [Result(title, int(size), url, provider, kind, True)]
|
||||
result = [(title, int(size), url, provider, kind)]
|
||||
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")
|
||||
data, result = searcher.preprocess(result)
|
||||
(data, bestqual) = searcher.preprocess(result)
|
||||
|
||||
if data and result:
|
||||
if data and bestqual:
|
||||
myDB = db.DBConnection()
|
||||
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'}
|
||||
else:
|
||||
return {'result': 'failure'}
|
||||
@@ -585,7 +590,7 @@ class WebInterface(object):
|
||||
for albums in have_albums:
|
||||
# Have to skip over manually matched tracks
|
||||
if albums['ArtistName'] and albums['AlbumTitle'] and albums['TrackTitle']:
|
||||
original_clean = clean_name(
|
||||
original_clean = helpers.clean_name(
|
||||
albums['ArtistName'] + " " + albums['AlbumTitle'] + " " + albums['TrackTitle'])
|
||||
# else:
|
||||
# original_clean = None
|
||||
@@ -632,8 +637,8 @@ class WebInterface(object):
|
||||
(artist, album))
|
||||
|
||||
elif action == "matchArtist":
|
||||
existing_artist_clean = clean_name(existing_artist).lower()
|
||||
new_artist_clean = clean_name(new_artist).lower()
|
||||
existing_artist_clean = helpers.clean_name(existing_artist).lower()
|
||||
new_artist_clean = helpers.clean_name(new_artist).lower()
|
||||
if new_artist_clean != existing_artist_clean:
|
||||
have_tracks = myDB.action(
|
||||
'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)
|
||||
|
||||
elif action == "matchAlbum":
|
||||
existing_artist_clean = clean_name(existing_artist).lower()
|
||||
new_artist_clean = clean_name(new_artist).lower()
|
||||
existing_album_clean = clean_name(existing_album).lower()
|
||||
new_album_clean = clean_name(new_album).lower()
|
||||
existing_artist_clean = helpers.clean_name(existing_artist).lower()
|
||||
new_artist_clean = helpers.clean_name(new_artist).lower()
|
||||
existing_album_clean = helpers.clean_name(existing_album).lower()
|
||||
new_album_clean = helpers.clean_name(new_album).lower()
|
||||
existing_clean_string = existing_artist_clean + " " + existing_album_clean
|
||||
new_clean_string = new_artist_clean + " " + new_album_clean
|
||||
if existing_clean_string != new_clean_string:
|
||||
@@ -736,7 +741,7 @@ class WebInterface(object):
|
||||
'SELECT ArtistName, AlbumTitle, TrackTitle, CleanName, Matched from have')
|
||||
for albums in manualalbums:
|
||||
if albums['ArtistName'] and albums['AlbumTitle'] and albums['TrackTitle']:
|
||||
original_clean = clean_name(
|
||||
original_clean = helpers.clean_name(
|
||||
albums['ArtistName'] + " " + albums['AlbumTitle'] + " " + albums['TrackTitle'])
|
||||
if albums['Matched'] == "Ignored" or albums['Matched'] == "Manual" or albums[
|
||||
'CleanName'] != original_clean:
|
||||
@@ -777,14 +782,14 @@ class WebInterface(object):
|
||||
[artist])
|
||||
update_count = 0
|
||||
for tracks in update_clean:
|
||||
original_clean = clean_name(
|
||||
original_clean = helpers.clean_name(
|
||||
tracks['ArtistName'] + " " + tracks['AlbumTitle'] + " " + tracks[
|
||||
'TrackTitle']).lower()
|
||||
album = tracks['AlbumTitle']
|
||||
track_title = tracks['TrackTitle']
|
||||
if tracks['CleanName'] != original_clean:
|
||||
artist_id_check = myDB.action('SELECT ArtistID FROM tracks WHERE CleanName = ?',
|
||||
[tracks['CleanName']]).fetchone()
|
||||
[tracks['CleanName']]).fetchone()
|
||||
if artist_id_check:
|
||||
artist_id = artist_id_check[0]
|
||||
myDB.action(
|
||||
@@ -809,7 +814,7 @@ class WebInterface(object):
|
||||
(artist, album))
|
||||
update_count = 0
|
||||
for tracks in update_clean:
|
||||
original_clean = clean_name(
|
||||
original_clean = helpers.clean_name(
|
||||
tracks['ArtistName'] + " " + tracks['AlbumTitle'] + " " + tracks[
|
||||
'TrackTitle']).lower()
|
||||
track_title = tracks['TrackTitle']
|
||||
@@ -1017,7 +1022,9 @@ class WebInterface(object):
|
||||
totalcount = myDB.select('SELECT COUNT(*) from artists')[0][0]
|
||||
|
||||
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
|
||||
# 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']
|
||||
counter += 1
|
||||
|
||||
return data
|
||||
return data
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@@ -1183,7 +1190,6 @@ class WebInterface(object):
|
||||
"deluge_password": headphones.CONFIG.DELUGE_PASSWORD,
|
||||
"deluge_label": headphones.CONFIG.DELUGE_LABEL,
|
||||
"deluge_done_directory": headphones.CONFIG.DELUGE_DONE_DIRECTORY,
|
||||
"deluge_download_directory": headphones.CONFIG.DELUGE_DOWNLOAD_DIRECTORY,
|
||||
"deluge_paused": checked(headphones.CONFIG.DELUGE_PAUSED),
|
||||
"utorrent_host": headphones.CONFIG.UTORRENT_HOST,
|
||||
"utorrent_username": headphones.CONFIG.UTORRENT_USERNAME,
|
||||
@@ -1198,8 +1204,6 @@ class WebInterface(object):
|
||||
"torrent_downloader_deluge": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 3),
|
||||
"torrent_downloader_qbittorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 4),
|
||||
"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),
|
||||
"blackhole_dir": headphones.CONFIG.BLACKHOLE_DIR,
|
||||
"usenet_retention": headphones.CONFIG.USENET_RETENTION,
|
||||
@@ -1231,6 +1235,13 @@ class WebInterface(object):
|
||||
"use_piratebay": checked(headphones.CONFIG.PIRATEBAY),
|
||||
"piratebay_proxy_url": headphones.CONFIG.PIRATEBAY_PROXY_URL,
|
||||
"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),
|
||||
"rutracker_user": headphones.CONFIG.RUTRACKER_USER,
|
||||
"rutracker_password": headphones.CONFIG.RUTRACKER_PASSWORD,
|
||||
@@ -1242,7 +1253,6 @@ class WebInterface(object):
|
||||
"orpheus_ratio": headphones.CONFIG.ORPHEUS_RATIO,
|
||||
"orpheus_url": headphones.CONFIG.ORPHEUS_URL,
|
||||
"use_redacted": checked(headphones.CONFIG.REDACTED),
|
||||
"redacted_apikey": headphones.CONFIG.REDACTED_APIKEY,
|
||||
"redacted_username": headphones.CONFIG.REDACTED_USERNAME,
|
||||
"redacted_password": headphones.CONFIG.REDACTED_PASSWORD,
|
||||
"redacted_ratio": headphones.CONFIG.REDACTED_RATIO,
|
||||
@@ -1265,7 +1275,6 @@ class WebInterface(object):
|
||||
"cue_split_shntool_path": headphones.CONFIG.CUE_SPLIT_SHNTOOL_PATH,
|
||||
"move_files": checked(headphones.CONFIG.MOVE_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),
|
||||
"cleanup_files": checked(headphones.CONFIG.CLEANUP_FILES),
|
||||
"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_1": radio(headphones.CONFIG.PREFER_TORRENTS, 1),
|
||||
"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_1": radio(headphones.CONFIG.MAGNET_LINKS, 1),
|
||||
"magnet_links_2": radio(headphones.CONFIG.MAGNET_LINKS, 2),
|
||||
@@ -1383,7 +1391,6 @@ class WebInterface(object):
|
||||
"custompass": headphones.CONFIG.CUSTOMPASS,
|
||||
"hpuser": headphones.CONFIG.HPUSER,
|
||||
"hppass": headphones.CONFIG.HPPASS,
|
||||
"lastfm_apikey": headphones.CONFIG.LASTFM_APIKEY,
|
||||
"songkick_enabled": checked(headphones.CONFIG.SONGKICK_ENABLED),
|
||||
"songkick_apikey": headphones.CONFIG.SONGKICK_APIKEY,
|
||||
"songkick_location": headphones.CONFIG.SONGKICK_LOCATION,
|
||||
@@ -1411,12 +1418,7 @@ class WebInterface(object):
|
||||
"join_enabled": checked(headphones.CONFIG.JOIN_ENABLED),
|
||||
"join_onsnatch": checked(headphones.CONFIG.JOIN_ONSNATCH),
|
||||
"join_apikey": headphones.CONFIG.JOIN_APIKEY,
|
||||
"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)
|
||||
"join_deviceid": headphones.CONFIG.JOIN_DEVICEID
|
||||
}
|
||||
|
||||
for k, v in config.items():
|
||||
@@ -1461,11 +1463,12 @@ class WebInterface(object):
|
||||
checked_configs = [
|
||||
"launch_browser", "enable_https", "api_enabled", "use_blackhole", "headphones_indexer",
|
||||
"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",
|
||||
"detect_bitrate", "ignore_clean_releases", "freeze_db", "cue_split", "move_files",
|
||||
"rename_files", "rename_single_disc_ignore", "correct_metadata", "cleanup_files",
|
||||
"keep_nfo", "add_album_art", "embed_album_art", "embed_lyrics",
|
||||
"rename_files", "correct_metadata", "cleanup_files", "keep_nfo", "add_album_art",
|
||||
"embed_album_art", "embed_lyrics",
|
||||
"replace_existing_folders", "keep_original_folder", "file_underscores",
|
||||
"include_extras", "official_releases_only",
|
||||
"wait_until_release_date", "autowant_upcoming", "autowant_all",
|
||||
@@ -1484,7 +1487,7 @@ class WebInterface(object):
|
||||
"songkick_enabled", "songkick_filter_enabled",
|
||||
"mpc_enabled", "email_enabled", "email_ssl", "email_tls", "email_onsnatch",
|
||||
"customauth", "idtag", "deluge_paused",
|
||||
"join_enabled", "join_onsnatch", "use_bandcamp", "use_soulseek"
|
||||
"join_enabled", "join_onsnatch"
|
||||
]
|
||||
for checked_config in checked_configs:
|
||||
if checked_config not in kwargs:
|
||||
|
||||
@@ -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:
|
||||
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
|
||||
__version__ = release # PEP 396
|
||||
|
||||
+21
-42
@@ -1,33 +1,25 @@
|
||||
__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_ERROR', 'EVENT_JOB_MISSED', 'EVENT_JOB_SUBMITTED', 'EVENT_JOB_MAX_INSTANCES',
|
||||
'SchedulerEvent', 'JobEvent', 'JobExecutionEvent', 'JobSubmissionEvent')
|
||||
__all__ = ('EVENT_SCHEDULER_START', 'EVENT_SCHEDULER_SHUTDOWN', '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_ERROR', 'EVENT_JOB_MISSED',
|
||||
'SchedulerEvent', 'JobEvent', 'JobExecutionEvent')
|
||||
|
||||
|
||||
EVENT_SCHEDULER_STARTED = EVENT_SCHEDULER_START = 2 ** 0
|
||||
EVENT_SCHEDULER_SHUTDOWN = 2 ** 1
|
||||
EVENT_SCHEDULER_PAUSED = 2 ** 2
|
||||
EVENT_SCHEDULER_RESUMED = 2 ** 3
|
||||
EVENT_EXECUTOR_ADDED = 2 ** 4
|
||||
EVENT_EXECUTOR_REMOVED = 2 ** 5
|
||||
EVENT_JOBSTORE_ADDED = 2 ** 6
|
||||
EVENT_JOBSTORE_REMOVED = 2 ** 7
|
||||
EVENT_ALL_JOBS_REMOVED = 2 ** 8
|
||||
EVENT_JOB_ADDED = 2 ** 9
|
||||
EVENT_JOB_REMOVED = 2 ** 10
|
||||
EVENT_JOB_MODIFIED = 2 ** 11
|
||||
EVENT_JOB_EXECUTED = 2 ** 12
|
||||
EVENT_JOB_ERROR = 2 ** 13
|
||||
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_SCHEDULER_START = 1
|
||||
EVENT_SCHEDULER_SHUTDOWN = 2
|
||||
EVENT_EXECUTOR_ADDED = 4
|
||||
EVENT_EXECUTOR_REMOVED = 8
|
||||
EVENT_JOBSTORE_ADDED = 16
|
||||
EVENT_JOBSTORE_REMOVED = 32
|
||||
EVENT_ALL_JOBS_REMOVED = 64
|
||||
EVENT_JOB_ADDED = 128
|
||||
EVENT_JOB_REMOVED = 256
|
||||
EVENT_JOB_MODIFIED = 512
|
||||
EVENT_JOB_EXECUTED = 1024
|
||||
EVENT_JOB_ERROR = 2048
|
||||
EVENT_JOB_MISSED = 4096
|
||||
EVENT_ALL = (EVENT_SCHEDULER_START | EVENT_SCHEDULER_SHUTDOWN | EVENT_JOBSTORE_ADDED | EVENT_JOBSTORE_REMOVED |
|
||||
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):
|
||||
@@ -63,21 +55,9 @@ class JobEvent(SchedulerEvent):
|
||||
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):
|
||||
"""
|
||||
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 retval: the return value of the successfully executed job
|
||||
@@ -85,8 +65,7 @@ class JobExecutionEvent(JobEvent):
|
||||
:ivar traceback: a formatted traceback for the exception
|
||||
"""
|
||||
|
||||
def __init__(self, code, job_id, jobstore, scheduled_run_time, retval=None, exception=None,
|
||||
traceback=None):
|
||||
def __init__(self, code, job_id, jobstore, scheduled_run_time, retval=None, exception=None, traceback=None):
|
||||
super(JobExecutionEvent, self).__init__(code, job_id, jobstore)
|
||||
self.scheduled_run_time = scheduled_run_time
|
||||
self.retval = retval
|
||||
|
||||
@@ -1,52 +1,28 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import sys
|
||||
|
||||
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):
|
||||
"""
|
||||
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``
|
||||
"""
|
||||
|
||||
def start(self, scheduler, alias):
|
||||
super(AsyncIOExecutor, self).start(scheduler, alias)
|
||||
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 callback(f):
|
||||
self._pending_futures.discard(f)
|
||||
try:
|
||||
events = f.result()
|
||||
except BaseException:
|
||||
except:
|
||||
self._run_job_error(job.id, *sys.exc_info()[1:])
|
||||
else:
|
||||
self._run_job_success(job.id, events)
|
||||
|
||||
if iscoroutinefunction_partial(job.func):
|
||||
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 = self._eventloop.run_in_executor(None, run_job, job, job._jobstore_alias, run_times, self._logger.name)
|
||||
f.add_done_callback(callback)
|
||||
self._pending_futures.add(f)
|
||||
|
||||
@@ -8,15 +8,13 @@ import sys
|
||||
from pytz import utc
|
||||
import six
|
||||
|
||||
from apscheduler.events import (
|
||||
JobExecutionEvent, EVENT_JOB_MISSED, EVENT_JOB_ERROR, EVENT_JOB_EXECUTED)
|
||||
from apscheduler.events import JobExecutionEvent, EVENT_JOB_MISSED, EVENT_JOB_ERROR, EVENT_JOB_EXECUTED
|
||||
|
||||
|
||||
class MaxInstancesReachedError(Exception):
|
||||
def __init__(self, job):
|
||||
super(MaxInstancesReachedError, self).__init__(
|
||||
'Job "%s" has already reached its maximum number of instances (%d)' %
|
||||
(job.id, job.max_instances))
|
||||
'Job "%s" has already reached its maximum number of instances (%d)' % (job.id, job.max_instances))
|
||||
|
||||
|
||||
class BaseExecutor(six.with_metaclass(ABCMeta, object)):
|
||||
@@ -32,14 +30,13 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
|
||||
|
||||
def start(self, scheduler, alias):
|
||||
"""
|
||||
Called by the scheduler when the scheduler is being started or when the executor is being
|
||||
added to an already running scheduler.
|
||||
Called by the scheduler when the scheduler is being started or when the executor is being added to an already
|
||||
running scheduler.
|
||||
|
||||
:param apscheduler.schedulers.base.BaseScheduler scheduler: the scheduler that is starting
|
||||
this executor
|
||||
:param apscheduler.schedulers.base.BaseScheduler scheduler: the scheduler that is starting this executor
|
||||
:param str|unicode alias: alias of this executor as it was assigned to the scheduler
|
||||
|
||||
"""
|
||||
|
||||
self._scheduler = scheduler
|
||||
self._lock = scheduler._create_lock()
|
||||
self._logger = logging.getLogger('apscheduler.executors.%s' % alias)
|
||||
@@ -48,8 +45,7 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
|
||||
"""
|
||||
Shuts down this executor.
|
||||
|
||||
:param bool wait: ``True`` to wait until all submitted jobs
|
||||
have been executed
|
||||
:param bool wait: ``True`` to wait until all submitted jobs have been executed
|
||||
"""
|
||||
|
||||
def submit_job(self, job, run_times):
|
||||
@@ -57,12 +53,10 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
|
||||
Submits job for execution.
|
||||
|
||||
:param Job job: job to execute
|
||||
:param list[datetime] run_times: list of datetimes specifying
|
||||
when the job should have been run
|
||||
:raises MaxInstancesReachedError: if the maximum number of
|
||||
allowed instances for this job has been reached
|
||||
|
||||
:param list[datetime] run_times: list of datetimes specifying when the job should have been run
|
||||
: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'
|
||||
with self._lock:
|
||||
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."""
|
||||
|
||||
def _run_job_success(self, job_id, events):
|
||||
"""
|
||||
Called by the executor with the list of generated events when :func:`run_job` has been
|
||||
successfully called.
|
||||
"""Called by the executor with the list of generated events when `run_job` has been successfully called."""
|
||||
|
||||
"""
|
||||
with self._lock:
|
||||
self._instances[job_id] -= 1
|
||||
if self._instances[job_id] == 0:
|
||||
del self._instances[job_id]
|
||||
|
||||
for event in events:
|
||||
self._scheduler._dispatch_event(event)
|
||||
|
||||
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:
|
||||
self._instances[job_id] -= 1
|
||||
if self._instances[job_id] == 0:
|
||||
del self._instances[job_id]
|
||||
|
||||
exc_info = (exc.__class__, exc, traceback)
|
||||
self._logger.error('Error running job %s', job_id, exc_info=exc_info)
|
||||
|
||||
|
||||
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 = []
|
||||
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
|
||||
# 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))
|
||||
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 = job.func(*job.args, **job.kwargs)
|
||||
except BaseException:
|
||||
except:
|
||||
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))
|
||||
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)
|
||||
|
||||
# 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:
|
||||
events.append(JobExecutionEvent(EVENT_JOB_EXECUTED, job.id, jobstore_alias, run_time,
|
||||
retval=retval))
|
||||
events.append(JobExecutionEvent(EVENT_JOB_EXECUTED, job.id, jobstore_alias, run_time, retval=retval))
|
||||
logger.info('Job "%s" executed successfully', job)
|
||||
|
||||
return events
|
||||
|
||||
@@ -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
|
||||
@@ -5,8 +5,7 @@ from apscheduler.executors.base import BaseExecutor, run_job
|
||||
|
||||
class DebugExecutor(BaseExecutor):
|
||||
"""
|
||||
A special executor that executes the target callable directly instead of deferring it to a
|
||||
thread or process.
|
||||
A special executor that executes the target callable directly instead of deferring it to a thread or process.
|
||||
|
||||
Plugin alias: ``debug``
|
||||
"""
|
||||
@@ -14,7 +13,7 @@ class DebugExecutor(BaseExecutor):
|
||||
def _do_submit_job(self, job, run_times):
|
||||
try:
|
||||
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:])
|
||||
else:
|
||||
self._run_job_success(job.id, events)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import sys
|
||||
|
||||
from apscheduler.executors.base import BaseExecutor, run_job
|
||||
@@ -21,10 +21,9 @@ class GeventExecutor(BaseExecutor):
|
||||
def callback(greenlet):
|
||||
try:
|
||||
events = greenlet.get()
|
||||
except BaseException:
|
||||
except:
|
||||
self._run_job_error(job.id, *sys.exc_info()[1:])
|
||||
else:
|
||||
self._run_job_success(job.id, events)
|
||||
|
||||
gevent.spawn(run_job, job, job._jobstore_alias, run_times, self._logger.name).\
|
||||
link(callback)
|
||||
gevent.spawn(run_job, job, job._jobstore_alias, run_times, self._logger.name).link(callback)
|
||||
|
||||
@@ -3,11 +3,6 @@ import concurrent.futures
|
||||
|
||||
from apscheduler.executors.base import BaseExecutor, run_job
|
||||
|
||||
try:
|
||||
from concurrent.futures.process import BrokenProcessPool
|
||||
except ImportError:
|
||||
BrokenProcessPool = None
|
||||
|
||||
|
||||
class BasePoolExecutor(BaseExecutor):
|
||||
@abstractmethod
|
||||
@@ -24,13 +19,7 @@ class BasePoolExecutor(BaseExecutor):
|
||||
else:
|
||||
self._run_job_success(job.id, f.result())
|
||||
|
||||
try:
|
||||
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 = self._pool.submit(run_job, job, job._jobstore_alias, run_times, self._logger.name)
|
||||
f.add_done_callback(callback)
|
||||
|
||||
def shutdown(self, wait=True):
|
||||
@@ -44,13 +33,10 @@ class ThreadPoolExecutor(BasePoolExecutor):
|
||||
Plugin alias: ``threadpool``
|
||||
|
||||
: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):
|
||||
pool_kwargs = pool_kwargs or {}
|
||||
pool = concurrent.futures.ThreadPoolExecutor(int(max_workers), **pool_kwargs)
|
||||
def __init__(self, max_workers=10):
|
||||
pool = concurrent.futures.ThreadPoolExecutor(int(max_workers))
|
||||
super(ThreadPoolExecutor, self).__init__(pool)
|
||||
|
||||
|
||||
@@ -61,11 +47,8 @@ class ProcessPoolExecutor(BasePoolExecutor):
|
||||
Plugin alias: ``processpool``
|
||||
|
||||
: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):
|
||||
pool_kwargs = pool_kwargs or {}
|
||||
pool = concurrent.futures.ProcessPoolExecutor(int(max_workers), **pool_kwargs)
|
||||
def __init__(self, max_workers=10):
|
||||
pool = concurrent.futures.ProcessPoolExecutor(int(max_workers))
|
||||
super(ProcessPoolExecutor, self).__init__(pool)
|
||||
|
||||
@@ -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)
|
||||
@@ -1,4 +1,4 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
|
||||
from apscheduler.executors.base import BaseExecutor, run_job
|
||||
|
||||
@@ -21,5 +21,5 @@ class TwistedExecutor(BaseExecutor):
|
||||
else:
|
||||
self._run_job_error(job.id, result.value, result.tb)
|
||||
|
||||
self._reactor.getThreadPool().callInThreadWithCallback(
|
||||
callback, run_job, job, job._jobstore_alias, run_times, self._logger.name)
|
||||
self._reactor.getThreadPool().callInThreadWithCallback(callback, run_job, job, job._jobstore_alias, run_times,
|
||||
self._logger.name)
|
||||
|
||||
+27
-77
@@ -1,17 +1,11 @@
|
||||
from inspect import ismethod, isclass
|
||||
from collections.abc import Iterable, Mapping
|
||||
from uuid import uuid4
|
||||
|
||||
import six
|
||||
|
||||
from apscheduler.triggers.base import BaseTrigger
|
||||
from apscheduler.util import (
|
||||
ref_to_obj, obj_to_ref, datetime_repr, repr_escape, get_callable_name, check_callable_args,
|
||||
convert_to_datetime)
|
||||
|
||||
try:
|
||||
from collections.abc import Iterable, Mapping
|
||||
except ImportError:
|
||||
from collections import Iterable, Mapping
|
||||
from apscheduler.util import ref_to_obj, obj_to_ref, datetime_repr, repr_escape, get_callable_name, check_callable_args, \
|
||||
convert_to_datetime
|
||||
|
||||
|
||||
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 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 int misfire_grace_time: the time (in seconds) how much this job's execution is allowed to
|
||||
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 misfire_grace_time: the time (in seconds) how much this job's execution is allowed to be late
|
||||
: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
|
||||
|
||||
.. 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',
|
||||
'args', 'kwargs', 'name', 'misfire_grace_time', 'coalesce', 'max_instances',
|
||||
'next_run_time', '__weakref__')
|
||||
__slots__ = ('_scheduler', '_jobstore_alias', 'id', 'trigger', 'executor', 'func', 'func_ref', 'args', 'kwargs',
|
||||
'name', 'misfire_grace_time', 'coalesce', 'max_instances', 'next_run_time')
|
||||
|
||||
def __init__(self, scheduler, id=None, **kwargs):
|
||||
super(Job, self).__init__()
|
||||
@@ -51,69 +38,53 @@ class Job(object):
|
||||
def modify(self, **changes):
|
||||
"""
|
||||
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.
|
||||
|
||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.modify_job`
|
||||
|
||||
:return Job: this job instance
|
||||
|
||||
"""
|
||||
|
||||
self._scheduler.modify_job(self.id, self._jobstore_alias, **changes)
|
||||
return self
|
||||
|
||||
def reschedule(self, trigger, **trigger_args):
|
||||
"""
|
||||
Shortcut for switching the trigger on this 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)
|
||||
return self
|
||||
|
||||
def pause(self):
|
||||
"""
|
||||
Temporarily suspend the execution of this job.
|
||||
|
||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.pause_job`
|
||||
|
||||
:return Job: this job instance
|
||||
|
||||
"""
|
||||
|
||||
self._scheduler.pause_job(self.id, self._jobstore_alias)
|
||||
return self
|
||||
|
||||
def resume(self):
|
||||
"""
|
||||
Resume the schedule of this job if previously paused.
|
||||
|
||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.resume_job`
|
||||
|
||||
:return Job: this job instance
|
||||
|
||||
"""
|
||||
|
||||
self._scheduler.resume_job(self.id, self._jobstore_alias)
|
||||
return self
|
||||
|
||||
def remove(self):
|
||||
"""
|
||||
Unschedules this job and removes it from its associated job store.
|
||||
|
||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.remove_job`
|
||||
|
||||
"""
|
||||
|
||||
self._scheduler.remove_job(self.id, self._jobstore_alias)
|
||||
|
||||
@property
|
||||
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
|
||||
|
||||
#
|
||||
@@ -126,8 +97,8 @@ class Job(object):
|
||||
|
||||
:type now: datetime.datetime
|
||||
:rtype: list[datetime.datetime]
|
||||
|
||||
"""
|
||||
|
||||
run_times = []
|
||||
next_run_time = self.next_run_time
|
||||
while next_run_time and next_run_time <= now:
|
||||
@@ -137,11 +108,8 @@ class Job(object):
|
||||
return run_times
|
||||
|
||||
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 = {}
|
||||
|
||||
if 'id' in changes:
|
||||
@@ -157,7 +125,7 @@ class Job(object):
|
||||
args = changes.pop('args') if 'args' in changes else self.args
|
||||
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_to_obj(func)
|
||||
elif callable(func):
|
||||
@@ -209,8 +177,7 @@ class Job(object):
|
||||
if 'trigger' in changes:
|
||||
trigger = changes.pop('trigger')
|
||||
if not isinstance(trigger, BaseTrigger):
|
||||
raise TypeError('Expected a trigger instance, got %s instead' %
|
||||
trigger.__class__.__name__)
|
||||
raise TypeError('Expected a trigger instance, got %s instead' % trigger.__class__.__name__)
|
||||
|
||||
approved['trigger'] = trigger
|
||||
|
||||
@@ -222,12 +189,10 @@ class Job(object):
|
||||
|
||||
if 'next_run_time' in changes:
|
||||
value = changes.pop('next_run_time')
|
||||
approved['next_run_time'] = convert_to_datetime(value, self._scheduler.timezone,
|
||||
'next_run_time')
|
||||
approved['next_run_time'] = convert_to_datetime(value, self._scheduler.timezone, 'next_run_time')
|
||||
|
||||
if changes:
|
||||
raise AttributeError('The following are not modifiable attributes of Job: %s' %
|
||||
', '.join(changes))
|
||||
raise AttributeError('The following are not modifiable attributes of Job: %s' % ', '.join(changes))
|
||||
|
||||
for key, value in six.iteritems(approved):
|
||||
setattr(self, key, value)
|
||||
@@ -235,18 +200,9 @@ class Job(object):
|
||||
def __getstate__(self):
|
||||
# Don't allow this Job to be serialized if the function reference could not be determined
|
||||
if not self.func_ref:
|
||||
raise ValueError(
|
||||
'This Job cannot be serialized since the reference to its callable (%r) could not '
|
||||
'be determined. Consider giving a textual reference (module:function name) '
|
||||
'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
|
||||
raise ValueError('This Job cannot be serialized since the reference to its callable (%r) could not be '
|
||||
'determined. Consider giving a textual reference (module:function name) instead.' %
|
||||
(self.func,))
|
||||
|
||||
return {
|
||||
'version': 1,
|
||||
@@ -254,7 +210,7 @@ class Job(object):
|
||||
'func': self.func_ref,
|
||||
'trigger': self.trigger,
|
||||
'executor': self.executor,
|
||||
'args': args,
|
||||
'args': self.args,
|
||||
'kwargs': self.kwargs,
|
||||
'name': self.name,
|
||||
'misfire_grace_time': self.misfire_grace_time,
|
||||
@@ -265,8 +221,7 @@ class Job(object):
|
||||
|
||||
def __setstate__(self, state):
|
||||
if state.get('version', 1) > 1:
|
||||
raise ValueError('Job has version %s, but only version 1 can be handled' %
|
||||
state['version'])
|
||||
raise ValueError('Job has version %s, but only version 1 can be handled' % state['version'])
|
||||
|
||||
self.id = state['id']
|
||||
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))
|
||||
|
||||
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):
|
||||
if hasattr(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)
|
||||
return six.u('%s (trigger: %s, next run at: %s)') % (self.name, self.trigger, datetime_repr(self.next_run_time))
|
||||
|
||||
@@ -8,27 +8,23 @@ class JobLookupError(KeyError):
|
||||
"""Raised when the job store cannot find a job for update or removal."""
|
||||
|
||||
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):
|
||||
"""Raised when the uniqueness of job IDs is being violated."""
|
||||
|
||||
def __init__(self, job_id):
|
||||
super(ConflictingIdError, self).__init__(
|
||||
u'Job identifier (%s) conflicts with an existing job' % job_id)
|
||||
super(ConflictingIdError, self).__init__(six.u('Job identifier (%s) conflicts with an existing job') % job_id)
|
||||
|
||||
|
||||
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):
|
||||
super(TransientJobError, self).__init__(
|
||||
u'Job (%s) cannot be added to this job store because a reference to the callable '
|
||||
u'could not be determined.' % job_id)
|
||||
six.u('Job (%s) cannot be added to this job store because a reference to the callable could not be '
|
||||
'determined.') % job_id)
|
||||
|
||||
|
||||
class BaseJobStore(six.with_metaclass(ABCMeta)):
|
||||
@@ -40,11 +36,10 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
|
||||
|
||||
def start(self, scheduler, alias):
|
||||
"""
|
||||
Called by the scheduler when the scheduler is being started or when the job store is being
|
||||
added to an already running scheduler.
|
||||
Called by the scheduler when the scheduler is being started or when the job store is being added to an already
|
||||
running scheduler.
|
||||
|
||||
:param apscheduler.schedulers.base.BaseScheduler scheduler: the scheduler that is starting
|
||||
this job store
|
||||
:param apscheduler.schedulers.base.BaseScheduler scheduler: the scheduler that is starting this job store
|
||||
: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):
|
||||
"""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
|
||||
def lookup_job(self, job_id):
|
||||
"""
|
||||
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 returned job to point to the scheduler and itself, respectively.
|
||||
The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of the returned job to
|
||||
point to the scheduler and itself, respectively.
|
||||
|
||||
:param str|unicode job_id: identifier of the job
|
||||
:rtype: Job
|
||||
@@ -89,8 +75,7 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
|
||||
@abstractmethod
|
||||
def get_next_run_time(self):
|
||||
"""
|
||||
Returns the earliest run time of all the jobs stored in this job store, or ``None`` if
|
||||
there are no active jobs.
|
||||
Returns the earliest run time of all the jobs stored in this job store, or ``None`` if there are no active jobs.
|
||||
|
||||
:rtype: datetime.datetime
|
||||
"""
|
||||
@@ -98,12 +83,11 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
|
||||
@abstractmethod
|
||||
def get_all_jobs(self):
|
||||
"""
|
||||
Returns a list of all jobs in this job store.
|
||||
The returned jobs should be sorted by next run time (ascending).
|
||||
Paused jobs (next_run_time == None) should be sorted last.
|
||||
Returns a list of all jobs in this job store. The returned jobs should be sorted by next run time (ascending).
|
||||
Paused jobs (next_run_time is None) should be sorted last.
|
||||
|
||||
The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of
|
||||
the returned jobs to point to the scheduler and itself, respectively.
|
||||
The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of the returned jobs to
|
||||
point to the scheduler and itself, respectively.
|
||||
|
||||
:rtype: list[Job]
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
|
||||
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
||||
from apscheduler.util import datetime_to_utc_timestamp
|
||||
@@ -13,8 +13,7 @@ class MemoryJobStore(BaseJobStore):
|
||||
|
||||
def __init__(self):
|
||||
super(MemoryJobStore, self).__init__()
|
||||
# list of (job, timestamp), sorted by next_run_time and job id (ascending)
|
||||
self._jobs = []
|
||||
self._jobs = [] # list of (job, timestamp), sorted by next_run_time and job id (ascending)
|
||||
self._jobs_index = {} # id -> (job, timestamp) lookup table
|
||||
|
||||
def lookup_job(self, job_id):
|
||||
@@ -81,13 +80,13 @@ class MemoryJobStore(BaseJobStore):
|
||||
|
||||
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
|
||||
inserted based on the given timestamp.
|
||||
Returns the index of the given job, or if it's not found, the index where the job should be inserted based on
|
||||
the given timestamp.
|
||||
|
||||
:type timestamp: int
|
||||
:type job_id: str
|
||||
|
||||
"""
|
||||
|
||||
lo, hi = 0, len(self._jobs)
|
||||
timestamp = float('inf') if timestamp is None else timestamp
|
||||
while lo < hi:
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from __future__ import absolute_import
|
||||
import warnings
|
||||
|
||||
|
||||
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
|
||||
import pickle as pickle
|
||||
except ImportError: # pragma: nocover
|
||||
import pickle
|
||||
|
||||
@@ -20,18 +19,16 @@ except ImportError: # pragma: nocover
|
||||
|
||||
class MongoDBJobStore(BaseJobStore):
|
||||
"""
|
||||
Stores jobs in a MongoDB database. Any leftover keyword arguments are directly passed to
|
||||
pymongo's `MongoClient
|
||||
Stores jobs in a MongoDB database. Any leftover keyword arguments are directly passed to pymongo's `MongoClient
|
||||
<http://api.mongodb.org/python/current/api/pymongo/mongo_client.html#pymongo.mongo_client.MongoClient>`_.
|
||||
|
||||
Plugin alias: ``mongodb``
|
||||
|
||||
:param str database: database 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
|
||||
providing connection arguments
|
||||
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the
|
||||
highest available
|
||||
:param client: a :class:`~pymongo.mongo_client.MongoClient` 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', collection='jobs', client=None,
|
||||
@@ -45,22 +42,13 @@ class MongoDBJobStore(BaseJobStore):
|
||||
raise ValueError('The "collection" parameter must not be empty')
|
||||
|
||||
if client:
|
||||
self.client = maybe_ref(client)
|
||||
self.connection = maybe_ref(client)
|
||||
else:
|
||||
connect_args.setdefault('w', 1)
|
||||
self.client = MongoClient(**connect_args)
|
||||
self.connection = MongoClient(**connect_args)
|
||||
|
||||
self.collection = self.client[database][collection]
|
||||
|
||||
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
|
||||
self.collection = self.connection[database][collection]
|
||||
self.collection.ensure_index('next_run_time', sparse=True)
|
||||
|
||||
def lookup_job(self, job_id):
|
||||
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}})
|
||||
|
||||
def get_next_run_time(self):
|
||||
document = self.collection.find_one({'next_run_time': {'$ne': None}},
|
||||
projection=['next_run_time'],
|
||||
document = self.collection.find_one({'next_run_time': {'$ne': None}}, fields=['next_run_time'],
|
||||
sort=[('next_run_time', ASCENDING)])
|
||||
return utc_timestamp_to_datetime(document['next_run_time']) if document else None
|
||||
|
||||
def get_all_jobs(self):
|
||||
jobs = self._get_jobs({})
|
||||
self._fix_paused_jobs_sorting(jobs)
|
||||
return jobs
|
||||
return self._get_jobs({})
|
||||
|
||||
def add_job(self, job):
|
||||
try:
|
||||
self.collection.insert_one({
|
||||
self.collection.insert({
|
||||
'_id': job.id,
|
||||
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||
'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),
|
||||
'job_state': Binary(pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
||||
}
|
||||
result = self.collection.update_one({'_id': job.id}, {'$set': changes})
|
||||
if result and result.matched_count == 0:
|
||||
raise JobLookupError(job.id)
|
||||
result = self.collection.update({'_id': job.id}, {'$set': changes})
|
||||
if result and result['n'] == 0:
|
||||
raise JobLookupError(id)
|
||||
|
||||
def remove_job(self, job_id):
|
||||
result = self.collection.delete_one({'_id': job_id})
|
||||
if result and result.deleted_count == 0:
|
||||
result = self.collection.remove(job_id)
|
||||
if result and result['n'] == 0:
|
||||
raise JobLookupError(job_id)
|
||||
|
||||
def remove_all_jobs(self):
|
||||
self.collection.delete_many({})
|
||||
self.collection.remove()
|
||||
|
||||
def shutdown(self):
|
||||
self.client.close()
|
||||
self.connection.disconnect()
|
||||
|
||||
def _reconstitute_job(self, job_state):
|
||||
job_state = pickle.loads(job_state)
|
||||
@@ -122,20 +107,18 @@ class MongoDBJobStore(BaseJobStore):
|
||||
def _get_jobs(self, conditions):
|
||||
jobs = []
|
||||
failed_job_ids = []
|
||||
for document in self.collection.find(conditions, ['_id', 'job_state'],
|
||||
sort=[('next_run_time', ASCENDING)]):
|
||||
for document in self.collection.find(conditions, ['_id', 'job_state'], sort=[('next_run_time', ASCENDING)]):
|
||||
try:
|
||||
jobs.append(self._reconstitute_job(document['job_state']))
|
||||
except BaseException:
|
||||
self._logger.exception('Unable to restore job "%s" -- removing it',
|
||||
document['_id'])
|
||||
except:
|
||||
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.collection.delete_many({'_id': {'$in': failed_job_ids}})
|
||||
self.collection.remove({'_id': {'$in': failed_job_ids}})
|
||||
|
||||
return jobs
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s (client=%s)>' % (self.__class__.__name__, self.client)
|
||||
return '<%s (client=%s)>' % (self.__class__.__name__, self.connection)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import absolute_import
|
||||
from datetime import datetime
|
||||
|
||||
from pytz import utc
|
||||
|
||||
import six
|
||||
|
||||
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
|
||||
|
||||
try:
|
||||
import cPickle as pickle
|
||||
import pickle as pickle
|
||||
except ImportError: # pragma: nocover
|
||||
import pickle
|
||||
|
||||
try:
|
||||
from redis import Redis
|
||||
from redis import StrictRedis
|
||||
except ImportError: # pragma: nocover
|
||||
raise ImportError('RedisJobStore requires redis installed')
|
||||
|
||||
|
||||
class RedisJobStore(BaseJobStore):
|
||||
"""
|
||||
Stores jobs in a Redis database. Any leftover keyword arguments are directly passed to redis's
|
||||
:class:`~redis.StrictRedis`.
|
||||
Stores jobs in a Redis database. Any leftover keyword arguments are directly passed to redis's StrictRedis.
|
||||
|
||||
Plugin alias: ``redis``
|
||||
|
||||
:param int db: the database number 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 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
|
||||
"""
|
||||
|
||||
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.jobs_key = jobs_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):
|
||||
job_state = self.redis.hget(self.jobs_key, job_id)
|
||||
@@ -69,8 +65,7 @@ class RedisJobStore(BaseJobStore):
|
||||
def get_all_jobs(self):
|
||||
job_states = self.redis.hgetall(self.jobs_key)
|
||||
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 or paused_sort_key)
|
||||
return sorted(jobs, key=lambda job: job.next_run_time)
|
||||
|
||||
def add_job(self, job):
|
||||
if self.redis.hexists(self.jobs_key, job.id):
|
||||
@@ -78,12 +73,8 @@ class RedisJobStore(BaseJobStore):
|
||||
|
||||
with self.redis.pipeline() as pipe:
|
||||
pipe.multi()
|
||||
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(),
|
||||
self.pickle_protocol))
|
||||
if job.next_run_time:
|
||||
pipe.zadd(self.run_times_key,
|
||||
{job.id: datetime_to_utc_timestamp(job.next_run_time)})
|
||||
|
||||
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
||||
pipe.zadd(self.run_times_key, datetime_to_utc_timestamp(job.next_run_time), job.id)
|
||||
pipe.execute()
|
||||
|
||||
def update_job(self, job):
|
||||
@@ -91,14 +82,11 @@ class RedisJobStore(BaseJobStore):
|
||||
raise JobLookupError(job.id)
|
||||
|
||||
with self.redis.pipeline() as pipe:
|
||||
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(),
|
||||
self.pickle_protocol))
|
||||
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
||||
if job.next_run_time:
|
||||
pipe.zadd(self.run_times_key,
|
||||
{job.id: datetime_to_utc_timestamp(job.next_run_time)})
|
||||
pipe.zadd(self.run_times_key, datetime_to_utc_timestamp(job.next_run_time), job.id)
|
||||
else:
|
||||
pipe.zrem(self.run_times_key, job.id)
|
||||
|
||||
pipe.execute()
|
||||
|
||||
def remove_job(self, job_id):
|
||||
@@ -133,7 +121,7 @@ class RedisJobStore(BaseJobStore):
|
||||
for job_id, job_state in job_states:
|
||||
try:
|
||||
jobs.append(self._reconstitute_job(job_state))
|
||||
except BaseException:
|
||||
except:
|
||||
self._logger.exception('Unable to restore job "%s" -- removing it', job_id)
|
||||
failed_job_ids.append(job_id)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -1,47 +1,38 @@
|
||||
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
|
||||
import pickle as pickle
|
||||
except ImportError: # pragma: nocover
|
||||
import pickle
|
||||
|
||||
try:
|
||||
from sqlalchemy import (
|
||||
create_engine, Table, Column, MetaData, Unicode, Float, LargeBinary, select, and_)
|
||||
from sqlalchemy import create_engine, Table, Column, MetaData, Unicode, Float, LargeBinary, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.sql.expression import null
|
||||
except ImportError: # pragma: nocover
|
||||
raise ImportError('SQLAlchemyJobStore requires SQLAlchemy installed')
|
||||
|
||||
|
||||
class SQLAlchemyJobStore(BaseJobStore):
|
||||
"""
|
||||
Stores jobs in a database table using SQLAlchemy.
|
||||
The table will be created if it doesn't exist in the database.
|
||||
Stores jobs in a database table using SQLAlchemy. The table will be created if it doesn't exist in the database.
|
||||
|
||||
Plugin alias: ``sqlalchemy``
|
||||
|
||||
:param str url: connection string (see
|
||||
:ref:`SQLAlchemy documentation <sqlalchemy:database_urls>` on this)
|
||||
:param engine: an SQLAlchemy :class:`~sqlalchemy.engine.Engine` to use instead of creating a
|
||||
new one based on ``url``
|
||||
:param str url: connection string (see `SQLAlchemy documentation
|
||||
<http://docs.sqlalchemy.org/en/latest/core/engines.html?highlight=create_engine#database-urls>`_
|
||||
on this)
|
||||
: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 metadata: a :class:`~sqlalchemy.schema.MetaData` instance to use instead of creating a
|
||||
new one
|
||||
: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)
|
||||
:param metadata: a :class:`~sqlalchemy.MetaData` instance to use instead of creating a new one
|
||||
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the highest available
|
||||
"""
|
||||
|
||||
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__()
|
||||
self.pickle_protocol = pickle_protocol
|
||||
metadata = maybe_ref(metadata) or MetaData()
|
||||
@@ -49,46 +40,37 @@ class SQLAlchemyJobStore(BaseJobStore):
|
||||
if engine:
|
||||
self.engine = maybe_ref(engine)
|
||||
elif url:
|
||||
self.engine = create_engine(url, **(engine_options or {}))
|
||||
self.engine = create_engine(url)
|
||||
else:
|
||||
raise ValueError('Need either "engine" or "url" defined')
|
||||
|
||||
# 191 = max key length in MySQL for InnoDB/utf8mb4 tables,
|
||||
# 25 = precision that translates to an 8-byte float
|
||||
# 191 = max key length in MySQL for InnoDB/utf8mb4 tables, 25 = precision that translates to an 8-byte float
|
||||
self.jobs_t = Table(
|
||||
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('job_state', LargeBinary, nullable=False),
|
||||
schema=tableschema
|
||||
Column('job_state', LargeBinary, nullable=False)
|
||||
)
|
||||
|
||||
def start(self, scheduler, alias):
|
||||
super(SQLAlchemyJobStore, self).start(scheduler, alias)
|
||||
self.jobs_t.create(self.engine, True)
|
||||
|
||||
def lookup_job(self, 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 = connection.execute(selectable).scalar()
|
||||
return self._reconstitute_job(job_state) if job_state else None
|
||||
selectable = select([self.jobs_t.c.job_state]).where(self.jobs_t.c.id == job_id)
|
||||
job_state = self.engine.execute(selectable).scalar()
|
||||
return self._reconstitute_job(job_state) if job_state else None
|
||||
|
||||
def get_due_jobs(self, now):
|
||||
timestamp = datetime_to_utc_timestamp(now)
|
||||
return self._get_jobs(self.jobs_t.c.next_run_time <= timestamp)
|
||||
|
||||
def get_next_run_time(self):
|
||||
selectable = select(self.jobs_t.c.next_run_time).\
|
||||
where(self.jobs_t.c.next_run_time != null()).\
|
||||
selectable = select([self.jobs_t.c.next_run_time]).where(self.jobs_t.c.next_run_time != None).\
|
||||
order_by(self.jobs_t.c.next_run_time).limit(1)
|
||||
with self.engine.begin() as connection:
|
||||
next_run_time = connection.execute(selectable).scalar()
|
||||
return utc_timestamp_to_datetime(next_run_time)
|
||||
next_run_time = self.engine.execute(selectable).scalar()
|
||||
return utc_timestamp_to_datetime(next_run_time)
|
||||
|
||||
def get_all_jobs(self):
|
||||
jobs = self._get_jobs()
|
||||
self._fix_paused_jobs_sorting(jobs)
|
||||
return jobs
|
||||
return self._get_jobs()
|
||||
|
||||
def add_job(self, job):
|
||||
insert = self.jobs_t.insert().values(**{
|
||||
@@ -96,33 +78,29 @@ class SQLAlchemyJobStore(BaseJobStore):
|
||||
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||
'job_state': pickle.dumps(job.__getstate__(), self.pickle_protocol)
|
||||
})
|
||||
with self.engine.begin() as connection:
|
||||
try:
|
||||
connection.execute(insert)
|
||||
except IntegrityError:
|
||||
raise ConflictingIdError(job.id)
|
||||
try:
|
||||
self.engine.execute(insert)
|
||||
except IntegrityError:
|
||||
raise ConflictingIdError(job.id)
|
||||
|
||||
def update_job(self, job):
|
||||
update = self.jobs_t.update().values(**{
|
||||
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||
'job_state': pickle.dumps(job.__getstate__(), self.pickle_protocol)
|
||||
}).where(self.jobs_t.c.id == job.id)
|
||||
with self.engine.begin() as connection:
|
||||
result = connection.execute(update)
|
||||
if result.rowcount == 0:
|
||||
raise JobLookupError(job.id)
|
||||
result = self.engine.execute(update)
|
||||
if result.rowcount == 0:
|
||||
raise JobLookupError(id)
|
||||
|
||||
def remove_job(self, job_id):
|
||||
delete = self.jobs_t.delete().where(self.jobs_t.c.id == job_id)
|
||||
with self.engine.begin() as connection:
|
||||
result = connection.execute(delete)
|
||||
if result.rowcount == 0:
|
||||
raise JobLookupError(job_id)
|
||||
result = self.engine.execute(delete)
|
||||
if result.rowcount == 0:
|
||||
raise JobLookupError(job_id)
|
||||
|
||||
def remove_all_jobs(self):
|
||||
delete = self.jobs_t.delete()
|
||||
with self.engine.begin() as connection:
|
||||
connection.execute(delete)
|
||||
self.engine.execute(delete)
|
||||
|
||||
def shutdown(self):
|
||||
self.engine.dispose()
|
||||
@@ -138,22 +116,20 @@ class SQLAlchemyJobStore(BaseJobStore):
|
||||
|
||||
def _get_jobs(self, *conditions):
|
||||
jobs = []
|
||||
selectable = select(self.jobs_t.c.id, self.jobs_t.c.job_state).\
|
||||
order_by(self.jobs_t.c.next_run_time)
|
||||
selectable = selectable.where(and_(*conditions)) if conditions else selectable
|
||||
selectable = select([self.jobs_t.c.id, self.jobs_t.c.job_state]).order_by(self.jobs_t.c.next_run_time)
|
||||
selectable = selectable.where(*conditions) if conditions else selectable
|
||||
failed_job_ids = set()
|
||||
with self.engine.begin() as connection:
|
||||
for row in connection.execute(selectable):
|
||||
try:
|
||||
jobs.append(self._reconstitute_job(row.job_state))
|
||||
except BaseException:
|
||||
self._logger.exception('Unable to restore job "%s" -- removing it', row.id)
|
||||
failed_job_ids.add(row.id)
|
||||
for row in self.engine.execute(selectable):
|
||||
try:
|
||||
jobs.append(self._reconstitute_job(row.job_state))
|
||||
except:
|
||||
self._logger.exception('Unable to restore job "%s" -- removing it', row.id)
|
||||
failed_job_ids.add(row.id)
|
||||
|
||||
# Remove all the jobs we failed to restore
|
||||
if failed_job_ids:
|
||||
delete = self.jobs_t.delete().where(self.jobs_t.c.id.in_(failed_job_ids))
|
||||
connection.execute(delete)
|
||||
# Remove all the jobs we failed to restore
|
||||
if failed_job_ids:
|
||||
delete = self.jobs_t.delete().where(self.jobs_t.c.id.in_(failed_job_ids))
|
||||
self.engine.execute(delete)
|
||||
|
||||
return jobs
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -1,16 +1,22 @@
|
||||
from __future__ import absolute_import
|
||||
import asyncio
|
||||
from functools import wraps, partial
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from apscheduler.schedulers.base import BaseScheduler
|
||||
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):
|
||||
@wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
wrapped = partial(func, self, *args, **kwargs)
|
||||
self._eventloop.call_soon_threadsafe(wrapped)
|
||||
self._eventloop.call_soon_threadsafe(func, self, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
@@ -18,8 +24,6 @@ class AsyncIOScheduler(BaseScheduler):
|
||||
"""
|
||||
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:
|
||||
|
||||
============== =============================================================
|
||||
@@ -30,11 +34,9 @@ class AsyncIOScheduler(BaseScheduler):
|
||||
_eventloop = None
|
||||
_timeout = None
|
||||
|
||||
def start(self, paused=False):
|
||||
if not self._eventloop:
|
||||
self._eventloop = asyncio.get_event_loop()
|
||||
|
||||
super(AsyncIOScheduler, self).start(paused)
|
||||
def start(self):
|
||||
super(AsyncIOScheduler, self).start()
|
||||
self.wakeup()
|
||||
|
||||
@run_in_event_loop
|
||||
def shutdown(self, wait=True):
|
||||
@@ -42,7 +44,7 @@ class AsyncIOScheduler(BaseScheduler):
|
||||
self._stop_timer()
|
||||
|
||||
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)
|
||||
|
||||
def _start_timer(self, wait_seconds):
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from threading import Thread, Event
|
||||
|
||||
@@ -14,12 +13,11 @@ class BackgroundScheduler(BlockingScheduler):
|
||||
|
||||
Extra options:
|
||||
|
||||
========== =============================================================================
|
||||
``daemon`` Set the ``daemon`` option in the background thread (defaults to ``True``, see
|
||||
`the documentation
|
||||
<https://docs.python.org/3.4/library/threading.html#thread-objects>`_
|
||||
========== ============================================================================================
|
||||
``daemon`` Set the ``daemon`` option in the background thread (defaults to ``True``,
|
||||
see `the documentation <https://docs.python.org/3.4/library/threading.html#thread-objects>`_
|
||||
for further details)
|
||||
========== =============================================================================
|
||||
========== ============================================================================================
|
||||
"""
|
||||
|
||||
_thread = None
|
||||
@@ -28,16 +26,14 @@ class BackgroundScheduler(BlockingScheduler):
|
||||
self._daemon = asbool(config.pop('daemon', True))
|
||||
super(BackgroundScheduler, self)._configure(config)
|
||||
|
||||
def start(self, *args, **kwargs):
|
||||
if self._event is None or self._event.is_set():
|
||||
self._event = Event()
|
||||
|
||||
BaseScheduler.start(self, *args, **kwargs)
|
||||
def start(self):
|
||||
BaseScheduler.start(self)
|
||||
self._event = Event()
|
||||
self._thread = Thread(target=self._main_loop, name='APScheduler')
|
||||
self._thread.daemon = self._daemon
|
||||
self._thread.start()
|
||||
|
||||
def shutdown(self, *args, **kwargs):
|
||||
super(BackgroundScheduler, self).shutdown(*args, **kwargs)
|
||||
def shutdown(self, wait=True):
|
||||
super(BackgroundScheduler, self).shutdown(wait)
|
||||
self._thread.join()
|
||||
del self._thread
|
||||
|
||||
+214
-395
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,21 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from threading import Event
|
||||
|
||||
from apscheduler.schedulers.base import BaseScheduler, STATE_STOPPED
|
||||
from apscheduler.util import TIMEOUT_MAX
|
||||
from apscheduler.schedulers.base import BaseScheduler
|
||||
|
||||
|
||||
class BlockingScheduler(BaseScheduler):
|
||||
"""
|
||||
A scheduler that runs in the foreground
|
||||
(:meth:`~apscheduler.schedulers.base.BaseScheduler.start` will block).
|
||||
A scheduler that runs in the foreground (:meth:`~apscheduler.schedulers.base.BaseScheduler.start` will block).
|
||||
"""
|
||||
|
||||
MAX_WAIT_TIME = 4294967 # Maximum value accepted by Event.wait() on Windows
|
||||
|
||||
_event = None
|
||||
|
||||
def start(self, *args, **kwargs):
|
||||
if self._event is None or self._event.is_set():
|
||||
self._event = Event()
|
||||
|
||||
super(BlockingScheduler, self).start(*args, **kwargs)
|
||||
def start(self):
|
||||
super(BlockingScheduler, self).start()
|
||||
self._event = Event()
|
||||
self._main_loop()
|
||||
|
||||
def shutdown(self, wait=True):
|
||||
@@ -25,11 +23,10 @@ class BlockingScheduler(BaseScheduler):
|
||||
self._event.set()
|
||||
|
||||
def _main_loop(self):
|
||||
wait_seconds = TIMEOUT_MAX
|
||||
while self.state != STATE_STOPPED:
|
||||
self._event.wait(wait_seconds)
|
||||
self._event.clear()
|
||||
while self.running:
|
||||
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):
|
||||
self._event.set()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
|
||||
from apscheduler.schedulers.blocking import BlockingScheduler
|
||||
from apscheduler.schedulers.base import BaseScheduler
|
||||
@@ -16,14 +16,14 @@ class GeventScheduler(BlockingScheduler):
|
||||
|
||||
_greenlet = None
|
||||
|
||||
def start(self, *args, **kwargs):
|
||||
def start(self):
|
||||
BaseScheduler.start(self)
|
||||
self._event = Event()
|
||||
BaseScheduler.start(self, *args, **kwargs)
|
||||
self._greenlet = gevent.spawn(self._main_loop)
|
||||
return self._greenlet
|
||||
|
||||
def shutdown(self, *args, **kwargs):
|
||||
super(GeventScheduler, self).shutdown(*args, **kwargs)
|
||||
def shutdown(self, wait=True):
|
||||
super(GeventScheduler, self).shutdown(wait)
|
||||
self._greenlet.join()
|
||||
del self._greenlet
|
||||
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
|
||||
from apscheduler.schedulers.base import BaseScheduler
|
||||
|
||||
try:
|
||||
from PyQt5.QtCore import QObject, QTimer
|
||||
except (ImportError, RuntimeError): # pragma: nocover
|
||||
except ImportError: # pragma: nocover
|
||||
try:
|
||||
from PyQt4.QtCore import QObject, QTimer
|
||||
except ImportError:
|
||||
try:
|
||||
from PySide6.QtCore import QObject, QTimer # noqa
|
||||
from PySide.QtCore import QObject, QTimer # flake8: noqa
|
||||
except ImportError:
|
||||
try:
|
||||
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')
|
||||
raise ImportError('QtScheduler requires either PyQt5, PyQt4 or PySide installed')
|
||||
|
||||
|
||||
class QtScheduler(BaseScheduler):
|
||||
@@ -26,15 +19,18 @@ class QtScheduler(BaseScheduler):
|
||||
|
||||
_timer = None
|
||||
|
||||
def shutdown(self, *args, **kwargs):
|
||||
super(QtScheduler, self).shutdown(*args, **kwargs)
|
||||
def start(self):
|
||||
super(QtScheduler, self).start()
|
||||
self.wakeup()
|
||||
|
||||
def shutdown(self, wait=True):
|
||||
super(QtScheduler, self).shutdown(wait)
|
||||
self._stop_timer()
|
||||
|
||||
def _start_timer(self, wait_seconds):
|
||||
self._stop_timer()
|
||||
if wait_seconds is not None:
|
||||
wait_time = min(int(wait_seconds * 1000), 2147483647)
|
||||
self._timer = QTimer.singleShot(wait_time, self._process_jobs)
|
||||
self._timer = QTimer.singleShot(wait_seconds * 1000, self._process_jobs)
|
||||
|
||||
def _stop_timer(self):
|
||||
if self._timer:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from datetime import timedelta
|
||||
from functools import wraps
|
||||
@@ -23,8 +22,6 @@ class TornadoScheduler(BaseScheduler):
|
||||
"""
|
||||
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)
|
||||
=========== ===============================================================
|
||||
@@ -33,6 +30,10 @@ class TornadoScheduler(BaseScheduler):
|
||||
_ioloop = None
|
||||
_timeout = None
|
||||
|
||||
def start(self):
|
||||
super(TornadoScheduler, self).start()
|
||||
self.wakeup()
|
||||
|
||||
@run_in_ioloop
|
||||
def shutdown(self, wait=True):
|
||||
super(TornadoScheduler, self).shutdown(wait)
|
||||
@@ -52,10 +53,6 @@ class TornadoScheduler(BaseScheduler):
|
||||
self._ioloop.remove_timeout(self._timeout)
|
||||
del self._timeout
|
||||
|
||||
def _create_default_executor(self):
|
||||
from apscheduler.executors.tornado import TornadoExecutor
|
||||
return TornadoExecutor()
|
||||
|
||||
@run_in_ioloop
|
||||
def wakeup(self):
|
||||
self._stop_timer()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
from functools import wraps
|
||||
|
||||
@@ -36,6 +35,10 @@ class TwistedScheduler(BaseScheduler):
|
||||
self._reactor = maybe_ref(config.pop('reactor', default_reactor))
|
||||
super(TwistedScheduler, self)._configure(config)
|
||||
|
||||
def start(self):
|
||||
super(TwistedScheduler, self).start()
|
||||
self.wakeup()
|
||||
|
||||
@run_in_reactor
|
||||
def shutdown(self, wait=True):
|
||||
super(TwistedScheduler, self).shutdown(wait)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from datetime import timedelta
|
||||
import random
|
||||
|
||||
import six
|
||||
|
||||
@@ -8,30 +6,11 @@ import six
|
||||
class BaseTrigger(six.with_metaclass(ABCMeta)):
|
||||
"""Abstract base class that defines the interface that every trigger must implement."""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
@abstractmethod
|
||||
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
|
||||
``None``.
|
||||
Returns the next datetime to fire on, If no such datetime can be calculated, returns ``None``.
|
||||
|
||||
:param datetime.datetime previous_fire_time: the previous time the trigger was fired
|
||||
: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))
|
||||
|
||||
@@ -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))
|
||||
@@ -4,20 +4,17 @@ from tzlocal import get_localzone
|
||||
import six
|
||||
|
||||
from apscheduler.triggers.base import BaseTrigger
|
||||
from apscheduler.triggers.cron.fields import (
|
||||
BaseField, MonthField, WeekField, DayOfMonthField, DayOfWeekField, DEFAULT_VALUES)
|
||||
from apscheduler.util import (
|
||||
datetime_ceil, convert_to_datetime, datetime_repr, astimezone, localize, normalize)
|
||||
from apscheduler.triggers.cron.fields import BaseField, WeekField, DayOfMonthField, DayOfWeekField, DEFAULT_VALUES
|
||||
from apscheduler.util import datetime_ceil, convert_to_datetime, datetime_repr, astimezone
|
||||
|
||||
|
||||
class CronTrigger(BaseTrigger):
|
||||
"""
|
||||
Triggers when current time matches all specified time constraints,
|
||||
similarly to how the UNIX cron scheduler works.
|
||||
Triggers when current time matches all specified time constraints, similarly to how the UNIX cron scheduler works.
|
||||
|
||||
:param int|str year: 4-digit year
|
||||
: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 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)
|
||||
@@ -25,9 +22,8 @@ class CronTrigger(BaseTrigger):
|
||||
:param int|str second: second (0-59)
|
||||
: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.tzinfo|str timezone: time zone to use for the date/time calculations (defaults
|
||||
to scheduler timezone)
|
||||
:param int|None jitter: delay the job execution by ``jitter`` seconds at most
|
||||
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations
|
||||
(defaults to scheduler timezone)
|
||||
|
||||
.. 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')
|
||||
FIELDS_MAP = {
|
||||
'year': BaseField,
|
||||
'month': MonthField,
|
||||
'month': BaseField,
|
||||
'week': WeekField,
|
||||
'day': DayOfMonthField,
|
||||
'day_of_week': DayOfWeekField,
|
||||
@@ -44,16 +40,15 @@ class CronTrigger(BaseTrigger):
|
||||
'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,
|
||||
minute=None, second=None, start_date=None, end_date=None, timezone=None,
|
||||
jitter=None):
|
||||
def __init__(self, year=None, month=None, day=None, week=None, day_of_week=None, hour=None, minute=None,
|
||||
second=None, start_date=None, end_date=None, timezone=None):
|
||||
if 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
|
||||
elif isinstance(end_date, datetime) and end_date.tzinfo:
|
||||
elif end_date and end_date.tzinfo:
|
||||
self.timezone = end_date.tzinfo
|
||||
else:
|
||||
self.timezone = get_localzone()
|
||||
@@ -61,8 +56,6 @@ class CronTrigger(BaseTrigger):
|
||||
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.jitter = jitter
|
||||
|
||||
values = dict((key, value) for (key, value) in six.iteritems(locals())
|
||||
if key in self.FIELD_NAMES and value is not None)
|
||||
self.fields = []
|
||||
@@ -83,35 +76,13 @@ class CronTrigger(BaseTrigger):
|
||||
field = field_class(field_name, exprs, is_default)
|
||||
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):
|
||||
"""
|
||||
Increments the designated field and resets all less significant fields to their minimum
|
||||
values.
|
||||
Increments the designated field and resets all less significant fields to their minimum values.
|
||||
|
||||
:type dateval: datetime
|
||||
:type fieldnum: int
|
||||
:return: a tuple containing the new date, and the number of the field that was actually
|
||||
incremented
|
||||
:return: a tuple containing the new date, and the number of the field that was actually incremented
|
||||
:rtype: tuple
|
||||
"""
|
||||
|
||||
@@ -144,7 +115,7 @@ class CronTrigger(BaseTrigger):
|
||||
i += 1
|
||||
|
||||
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):
|
||||
values = {}
|
||||
@@ -157,13 +128,12 @@ class CronTrigger(BaseTrigger):
|
||||
else:
|
||||
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):
|
||||
if previous_fire_time:
|
||||
start_date = min(now, previous_fire_time + timedelta(microseconds=1))
|
||||
if start_date == previous_fire_time:
|
||||
start_date += timedelta(microseconds=1)
|
||||
start_date = max(now, previous_fire_time + timedelta(microseconds=1))
|
||||
else:
|
||||
start_date = max(now, self.start_date) if self.start_date else now
|
||||
|
||||
@@ -193,34 +163,7 @@ class CronTrigger(BaseTrigger):
|
||||
return None
|
||||
|
||||
if fieldnum >= 0:
|
||||
next_date = self._apply_jitter(next_date, self.jitter, now)
|
||||
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')
|
||||
return next_date
|
||||
|
||||
def __str__(self):
|
||||
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):
|
||||
options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default]
|
||||
if self.start_date:
|
||||
options.append("start_date=%r" % datetime_repr(self.start_date))
|
||||
if self.end_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)
|
||||
options.append("start_date='%s'" % datetime_repr(self.start_date))
|
||||
return '<%s (%s)>' % (self.__class__.__name__, ', '.join(options))
|
||||
|
||||
@@ -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
|
||||
import re
|
||||
|
||||
from apscheduler.util import asint
|
||||
|
||||
__all__ = ('AllExpression', 'RangeExpression', 'WeekdayRangeExpression',
|
||||
'WeekdayPositionExpression', 'LastDayOfMonthExpression')
|
||||
__all__ = ('AllExpression', 'RangeExpression', 'WeekdayRangeExpression', 'WeekdayPositionExpression',
|
||||
'LastDayOfMonthExpression')
|
||||
|
||||
|
||||
WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||
MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
|
||||
|
||||
|
||||
class AllExpression(object):
|
||||
@@ -21,14 +22,6 @@ class AllExpression(object):
|
||||
if self.step == 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):
|
||||
start = field.get_value(date)
|
||||
minval = field.get_min(date)
|
||||
@@ -44,9 +37,6 @@ class AllExpression(object):
|
||||
if next <= maxval:
|
||||
return next
|
||||
|
||||
def __eq__(self, other):
|
||||
return isinstance(other, self.__class__) and self.step == other.step
|
||||
|
||||
def __str__(self):
|
||||
if self.step:
|
||||
return '*/%d' % self.step
|
||||
@@ -61,7 +51,7 @@ class RangeExpression(AllExpression):
|
||||
r'(?P<first>\d+)(?:-(?P<last>\d+))?(?:/(?P<step>\d+))?$')
|
||||
|
||||
def __init__(self, first, last=None, step=None):
|
||||
super(RangeExpression, self).__init__(step)
|
||||
AllExpression.__init__(self, step)
|
||||
first = asint(first)
|
||||
last = asint(last)
|
||||
if last is None and step is None:
|
||||
@@ -71,41 +61,25 @@ class RangeExpression(AllExpression):
|
||||
self.first = first
|
||||
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):
|
||||
startval = field.get_value(date)
|
||||
start = field.get_value(date)
|
||||
minval = field.get_min(date)
|
||||
maxval = field.get_max(date)
|
||||
|
||||
# Apply range limits
|
||||
minval = max(minval, self.first)
|
||||
maxval = min(maxval, self.last) if self.last is not None else maxval
|
||||
nextval = max(minval, startval)
|
||||
if self.last is not None:
|
||||
maxval = min(maxval, self.last)
|
||||
start = max(start, minval)
|
||||
|
||||
# Apply the step if defined
|
||||
if self.step:
|
||||
distance_to_next = (self.step - (nextval - minval)) % self.step
|
||||
nextval += distance_to_next
|
||||
if not self.step:
|
||||
next = start
|
||||
else:
|
||||
distance_to_next = (self.step - (start - minval)) % self.step
|
||||
next = start + distance_to_next
|
||||
|
||||
return nextval if nextval <= maxval else None
|
||||
|
||||
def __eq__(self, other):
|
||||
return (isinstance(other, self.__class__) and self.first == other.first and
|
||||
self.last == other.last)
|
||||
if next <= maxval:
|
||||
return next
|
||||
|
||||
def __str__(self):
|
||||
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))
|
||||
|
||||
|
||||
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):
|
||||
value_re = re.compile(r'(?P<first>[a-z]+)(?:-(?P<last>[a-z]+))?', re.IGNORECASE)
|
||||
|
||||
@@ -174,7 +117,7 @@ class WeekdayRangeExpression(RangeExpression):
|
||||
else:
|
||||
last_num = None
|
||||
|
||||
super(WeekdayRangeExpression, self).__init__(first_num, last_num)
|
||||
RangeExpression.__init__(self, first_num, last_num)
|
||||
|
||||
def __str__(self):
|
||||
if self.last != self.first and self.last is not None:
|
||||
@@ -190,11 +133,9 @@ class WeekdayRangeExpression(RangeExpression):
|
||||
|
||||
class WeekdayPositionExpression(AllExpression):
|
||||
options = ['1st', '2nd', '3rd', '4th', '5th', 'last']
|
||||
value_re = re.compile(r'(?P<option_name>%s) +(?P<weekday_name>(?:\d+|\w+))' %
|
||||
'|'.join(options), re.IGNORECASE)
|
||||
value_re = re.compile(r'(?P<option_name>%s) +(?P<weekday_name>(?:\d+|\w+))' % '|'.join(options), re.IGNORECASE)
|
||||
|
||||
def __init__(self, option_name, weekday_name):
|
||||
super(WeekdayPositionExpression, self).__init__(None)
|
||||
try:
|
||||
self.option_num = self.options.index(option_name.lower())
|
||||
except ValueError:
|
||||
@@ -206,7 +147,8 @@ class WeekdayPositionExpression(AllExpression):
|
||||
raise ValueError('Invalid weekday name "%s"' % weekday_name)
|
||||
|
||||
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)
|
||||
|
||||
# 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:
|
||||
target_day = first_hit_day + self.option_num * 7
|
||||
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:
|
||||
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):
|
||||
return '%s %s' % (self.options[self.option_num], WEEKDAYS[self.weekday])
|
||||
|
||||
def __repr__(self):
|
||||
return "%s('%s', '%s')" % (self.__class__.__name__, self.options[self.option_num],
|
||||
WEEKDAYS[self.weekday])
|
||||
return "%s('%s', '%s')" % (self.__class__.__name__, self.options[self.option_num], WEEKDAYS[self.weekday])
|
||||
|
||||
|
||||
class LastDayOfMonthExpression(AllExpression):
|
||||
value_re = re.compile(r'last', re.IGNORECASE)
|
||||
|
||||
def __init__(self):
|
||||
super(LastDayOfMonthExpression, self).__init__(None)
|
||||
pass
|
||||
|
||||
def get_next_value(self, date, field):
|
||||
return monthrange(date.year, date.month)[1]
|
||||
|
||||
@@ -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
|
||||
import re
|
||||
|
||||
import six
|
||||
|
||||
from apscheduler.triggers.cron.expressions import (
|
||||
AllExpression, RangeExpression, WeekdayPositionExpression, LastDayOfMonthExpression,
|
||||
WeekdayRangeExpression, MonthRangeExpression)
|
||||
AllExpression, RangeExpression, WeekdayPositionExpression, LastDayOfMonthExpression, WeekdayRangeExpression)
|
||||
|
||||
|
||||
__all__ = ('MIN_VALUES', 'MAX_VALUES', 'DEFAULT_VALUES', 'BaseField', 'WeekField',
|
||||
'DayOfMonthField', 'DayOfWeekField')
|
||||
__all__ = ('MIN_VALUES', 'MAX_VALUES', 'DEFAULT_VALUES', 'BaseField', 'WeekField', 'DayOfMonthField', 'DayOfWeekField')
|
||||
|
||||
|
||||
MIN_VALUES = {'year': 1970, 'month': 1, 'day': 1, 'week': 1, 'day_of_week': 0, 'hour': 0,
|
||||
'minute': 0, 'second': 0}
|
||||
MAX_VALUES = {'year': 9999, 'month': 12, 'day': 31, 'week': 53, 'day_of_week': 6, 'hour': 23,
|
||||
'minute': 59, 'second': 59}
|
||||
DEFAULT_VALUES = {'year': '*', 'month': 1, 'day': 1, 'week': '*', 'day_of_week': '*', 'hour': 0,
|
||||
'minute': 0, 'second': 0}
|
||||
SEPARATOR = re.compile(' *, *')
|
||||
MIN_VALUES = {'year': 1970, 'month': 1, 'day': 1, 'week': 1, 'day_of_week': 0, 'hour': 0, 'minute': 0, 'second': 0}
|
||||
MAX_VALUES = {'year': 2 ** 63, 'month': 12, 'day:': 31, 'week': 53, 'day_of_week': 6, 'hour': 23, 'minute': 59,
|
||||
'second': 59}
|
||||
DEFAULT_VALUES = {'year': '*', 'month': 1, 'day': 1, 'week': '*', 'day_of_week': '*', 'hour': 0, 'minute': 0,
|
||||
'second': 0}
|
||||
|
||||
|
||||
class BaseField(object):
|
||||
@@ -54,29 +50,23 @@ class BaseField(object):
|
||||
self.expressions = []
|
||||
|
||||
# Split a comma-separated expression list, if any
|
||||
for expr in SEPARATOR.split(str(exprs).strip()):
|
||||
self.compile_expression(expr)
|
||||
exprs = str(exprs).strip()
|
||||
if ',' in exprs:
|
||||
for expr in exprs.split(','):
|
||||
self.compile_expression(expr)
|
||||
else:
|
||||
self.compile_expression(exprs)
|
||||
|
||||
def compile_expression(self, expr):
|
||||
for compiler in self.COMPILERS:
|
||||
match = compiler.value_re.match(expr)
|
||||
if match:
|
||||
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)
|
||||
return
|
||||
|
||||
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):
|
||||
expr_strings = (str(e) for e in self.expressions)
|
||||
return ','.join(expr_strings)
|
||||
@@ -105,7 +95,3 @@ class DayOfWeekField(BaseField):
|
||||
|
||||
def get_value(self, dateval):
|
||||
return dateval.weekday()
|
||||
|
||||
|
||||
class MonthField(BaseField):
|
||||
COMPILERS = BaseField.COMPILERS + [MonthRangeExpression]
|
||||
|
||||
@@ -14,36 +14,15 @@ class DateTrigger(BaseTrigger):
|
||||
: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):
|
||||
timezone = astimezone(timezone) or get_localzone()
|
||||
if run_date is not None:
|
||||
self.run_date = convert_to_datetime(run_date, timezone, 'run_date')
|
||||
else:
|
||||
self.run_date = datetime.now(timezone)
|
||||
self.run_date = convert_to_datetime(run_date or datetime.now(), timezone, 'run_date')
|
||||
|
||||
def get_next_fire_time(self, previous_fire_time, now):
|
||||
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):
|
||||
return 'date[%s]' % datetime_repr(self.run_date)
|
||||
|
||||
|
||||
@@ -4,15 +4,13 @@ from math import ceil
|
||||
from tzlocal import get_localzone
|
||||
|
||||
from apscheduler.triggers.base import BaseTrigger
|
||||
from apscheduler.util import (
|
||||
convert_to_datetime, normalize, timedelta_seconds, datetime_repr,
|
||||
astimezone)
|
||||
from apscheduler.util import convert_to_datetime, timedelta_seconds, datetime_repr, astimezone
|
||||
|
||||
|
||||
class IntervalTrigger(BaseTrigger):
|
||||
"""
|
||||
Triggers on specified intervals, starting on ``start_date`` if specified, ``datetime.now()`` +
|
||||
interval otherwise.
|
||||
Triggers on specified intervals, starting on ``start_date`` if specified, ``datetime.now()`` + interval
|
||||
otherwise.
|
||||
|
||||
:param int weeks: number of weeks 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 end_date: latest possible date/time to trigger on
|
||||
: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,
|
||||
end_date=None, timezone=None, jitter=None):
|
||||
self.interval = timedelta(weeks=weeks, days=days, hours=hours, minutes=minutes,
|
||||
seconds=seconds)
|
||||
def __init__(self, weeks=0, days=0, hours=0, minutes=0, seconds=0, start_date=None, end_date=None, timezone=None):
|
||||
self.interval = timedelta(weeks=weeks, days=days, hours=hours, minutes=minutes, seconds=seconds)
|
||||
self.interval_length = timedelta_seconds(self.interval)
|
||||
if self.interval_length == 0:
|
||||
self.interval = timedelta(seconds=1)
|
||||
@@ -38,9 +33,9 @@ class IntervalTrigger(BaseTrigger):
|
||||
|
||||
if 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
|
||||
elif isinstance(end_date, datetime) and end_date.tzinfo:
|
||||
elif end_date and end_date.tzinfo:
|
||||
self.timezone = end_date.tzinfo
|
||||
else:
|
||||
self.timezone = get_localzone()
|
||||
@@ -49,8 +44,6 @@ class IntervalTrigger(BaseTrigger):
|
||||
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.jitter = jitter
|
||||
|
||||
def get_next_fire_time(self, previous_fire_time, now):
|
||||
if previous_fire_time:
|
||||
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_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:
|
||||
return 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')
|
||||
return self.timezone.normalize(next_fire_time)
|
||||
|
||||
def __str__(self):
|
||||
return 'interval[%s]' % str(self.interval)
|
||||
|
||||
def __repr__(self):
|
||||
options = ['interval=%r' % self.interval, 'start_date=%r' % datetime_repr(self.start_date)]
|
||||
if self.end_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)
|
||||
return "<%s (interval=%r, start_date='%s')>" % (self.__class__.__name__, self.interval,
|
||||
datetime_repr(self.start_date))
|
||||
|
||||
+111
-156
@@ -1,36 +1,29 @@
|
||||
"""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 inspect import isfunction, ismethod, getargspec
|
||||
from calendar import timegm
|
||||
from functools import partial
|
||||
from inspect import isclass, ismethod
|
||||
import re
|
||||
import sys
|
||||
|
||||
from pytz import timezone, utc, FixedOffset
|
||||
from pytz import timezone, utc
|
||||
import six
|
||||
|
||||
try:
|
||||
from inspect import signature
|
||||
except ImportError: # pragma: nocover
|
||||
from funcsigs import signature
|
||||
|
||||
try:
|
||||
from threading import TIMEOUT_MAX
|
||||
except ImportError:
|
||||
TIMEOUT_MAX = 4294967 # Maximum value accepted by Event.wait() on Windows
|
||||
try:
|
||||
from funcsigs import signature
|
||||
except ImportError:
|
||||
signature = None
|
||||
|
||||
__all__ = ('asint', 'asbool', 'astimezone', 'convert_to_datetime', 'datetime_to_utc_timestamp',
|
||||
'utc_timestamp_to_datetime', 'timedelta_seconds', 'datetime_ceil', 'get_callable_name',
|
||||
'obj_to_ref', 'ref_to_obj', 'maybe_ref', 'repr_escape', 'check_callable_args',
|
||||
'normalize', 'localize', 'TIMEOUT_MAX')
|
||||
'utc_timestamp_to_datetime', 'timedelta_seconds', 'datetime_ceil', 'get_callable_name', 'obj_to_ref',
|
||||
'ref_to_obj', 'maybe_ref', 'repr_escape', 'check_callable_args')
|
||||
|
||||
|
||||
class _Undefined(object):
|
||||
def __nonzero__(self):
|
||||
def __bool__(self):
|
||||
return False
|
||||
|
||||
def __bool__(self):
|
||||
@@ -39,18 +32,17 @@ class _Undefined(object):
|
||||
def __repr__(self):
|
||||
return '<undefined>'
|
||||
|
||||
|
||||
undefined = _Undefined() #: a unique object that only signifies that no value is defined
|
||||
|
||||
|
||||
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
|
||||
:rtype: int
|
||||
|
||||
"""
|
||||
|
||||
if text is not None:
|
||||
return int(text)
|
||||
|
||||
@@ -60,8 +52,8 @@ def asbool(obj):
|
||||
Interprets an object as a boolean value.
|
||||
|
||||
:rtype: bool
|
||||
|
||||
"""
|
||||
|
||||
if isinstance(obj, str):
|
||||
obj = obj.strip().lower()
|
||||
if obj in ('true', 'yes', 'on', 'y', 't', '1'):
|
||||
@@ -77,17 +69,15 @@ def astimezone(obj):
|
||||
Interprets an object as a timezone.
|
||||
|
||||
:rtype: tzinfo
|
||||
|
||||
"""
|
||||
|
||||
if isinstance(obj, six.string_types):
|
||||
return timezone(obj)
|
||||
if isinstance(obj, tzinfo):
|
||||
if obj.tzname(None) == 'local':
|
||||
raise ValueError(
|
||||
'Unable to determine the name of the local timezone -- you must explicitly '
|
||||
'specify the name of the local timezone. Please refrain from using timezones like '
|
||||
'EST to prevent problems with daylight saving time. Instead, use a locale based '
|
||||
'timezone name (such as Europe/Helsinki).')
|
||||
if not hasattr(obj, 'localize') or not hasattr(obj, 'normalize'):
|
||||
raise TypeError('Only timezones from the pytz library are supported')
|
||||
if obj.zone == 'local':
|
||||
raise ValueError('Unable to determine the name of the local timezone -- use an explicit timezone instead')
|
||||
return obj
|
||||
if obj is not None:
|
||||
raise TypeError('Expected tzinfo, got %s instead' % obj.__class__.__name__)
|
||||
@@ -95,30 +85,27 @@ def astimezone(obj):
|
||||
|
||||
_DATE_REGEX = re.compile(
|
||||
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<microsecond>\d{1,6}))?'
|
||||
r'(?P<timezone>Z|[+-]\d\d:\d\d)?)?$')
|
||||
r'(?: (?P<hour>\d{1,2}):(?P<minute>\d{1,2}):(?P<second>\d{1,2})'
|
||||
r'(?:\.(?P<microsecond>\d{1,6}))?)?')
|
||||
|
||||
|
||||
def convert_to_datetime(input, tz, arg_name):
|
||||
"""
|
||||
Converts the given object to a timezone aware datetime object.
|
||||
|
||||
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 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
|
||||
(Y-m-d H:M:S) or with date+time with microseconds (Y-m-d H:M:S.micro). Additionally you can
|
||||
override the time zone by giving a specific offset in the format specified by ISO 8601:
|
||||
Z (UTC), +HH:MM or -HH:MM.
|
||||
Date strings are accepted in three different forms: date only (Y-m-d),
|
||||
date with time (Y-m-d H:M:S) or with date+time with microseconds
|
||||
(Y-m-d H:M:S.micro).
|
||||
|
||||
: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 str arg_name: the name of the argument (used in an error message)
|
||||
:rtype: datetime
|
||||
|
||||
"""
|
||||
|
||||
if input is None:
|
||||
return
|
||||
elif isinstance(input, datetime):
|
||||
@@ -129,17 +116,8 @@ def convert_to_datetime(input, tz, arg_name):
|
||||
m = _DATE_REGEX.match(input)
|
||||
if not m:
|
||||
raise ValueError('Invalid date string')
|
||||
|
||||
values = m.groupdict()
|
||||
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()}
|
||||
values = [(k, int(v or 0)) for k, v in list(m.groupdict().items())]
|
||||
values = dict(values)
|
||||
datetime_ = datetime(**values)
|
||||
else:
|
||||
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:
|
||||
return datetime_
|
||||
if tz is None:
|
||||
raise ValueError(
|
||||
'The "tz" argument must be specified if %s has no timezone information' % arg_name)
|
||||
raise ValueError('The "tz" argument must be specified if %s has no timezone information' % arg_name)
|
||||
if isinstance(tz, six.string_types):
|
||||
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):
|
||||
@@ -161,8 +141,8 @@ def datetime_to_utc_timestamp(timeval):
|
||||
|
||||
:type timeval: datetime
|
||||
:rtype: float
|
||||
|
||||
"""
|
||||
|
||||
if timeval is not None:
|
||||
return timegm(timeval.utctimetuple()) + timeval.microsecond / 1000000
|
||||
|
||||
@@ -173,8 +153,8 @@ def utc_timestamp_to_datetime(timestamp):
|
||||
|
||||
:type timestamp: float
|
||||
:rtype: datetime
|
||||
|
||||
"""
|
||||
|
||||
if timestamp is not None:
|
||||
return datetime.fromtimestamp(timestamp, utc)
|
||||
|
||||
@@ -185,8 +165,8 @@ def timedelta_seconds(delta):
|
||||
|
||||
:type delta: timedelta
|
||||
:rtype: float
|
||||
|
||||
"""
|
||||
|
||||
return delta.days * 24 * 60 * 60 + delta.seconds + \
|
||||
delta.microseconds / 1000000.0
|
||||
|
||||
@@ -196,8 +176,8 @@ def datetime_ceil(dateval):
|
||||
Rounds the given datetime object upwards.
|
||||
|
||||
:type dateval: datetime
|
||||
|
||||
"""
|
||||
|
||||
if dateval.microsecond > 0:
|
||||
return dateval + timedelta(seconds=1, microseconds=-dateval.microsecond)
|
||||
return dateval
|
||||
@@ -212,8 +192,8 @@ def get_callable_name(func):
|
||||
Returns the best available display name for the given function/callable.
|
||||
|
||||
:rtype: str
|
||||
|
||||
"""
|
||||
|
||||
# the easy case (on Python 3.3+)
|
||||
if hasattr(func, '__qualname__'):
|
||||
return func.__qualname__
|
||||
@@ -221,7 +201,7 @@ def get_callable_name(func):
|
||||
# class methods, bound and unbound methods
|
||||
f_self = getattr(func, '__self__', None) or getattr(func, 'im_self', None)
|
||||
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:
|
||||
f_class = getattr(func, 'im_class', None)
|
||||
|
||||
@@ -242,35 +222,20 @@ def get_callable_name(func):
|
||||
|
||||
def obj_to_ref(obj):
|
||||
"""
|
||||
Returns the path to the given callable.
|
||||
Returns the path to the given object.
|
||||
|
||||
: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)
|
||||
if '<lambda>' in name:
|
||||
raise ValueError('Cannot create a reference to a lambda')
|
||||
if '<locals>' in name:
|
||||
raise ValueError('Cannot create a reference to a nested function')
|
||||
try:
|
||||
ref = '%s:%s' % (obj.__module__, get_callable_name(obj))
|
||||
obj2 = ref_to_obj(ref)
|
||||
if obj != obj2:
|
||||
raise ValueError
|
||||
except Exception:
|
||||
raise ValueError('Cannot determine the reference to %r' % obj)
|
||||
|
||||
if ismethod(obj):
|
||||
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)
|
||||
return ref
|
||||
|
||||
|
||||
def ref_to_obj(ref):
|
||||
@@ -278,8 +243,8 @@ def ref_to_obj(ref):
|
||||
Returns the object pointed to by ``ref``.
|
||||
|
||||
:type ref: str
|
||||
|
||||
"""
|
||||
|
||||
if not isinstance(ref, six.string_types):
|
||||
raise TypeError('References must be strings')
|
||||
if ':' not in ref:
|
||||
@@ -287,12 +252,12 @@ def ref_to_obj(ref):
|
||||
|
||||
modulename, rest = ref.split(':', 1)
|
||||
try:
|
||||
obj = __import__(modulename, fromlist=[rest])
|
||||
obj = __import__(modulename)
|
||||
except ImportError:
|
||||
raise LookupError('Error resolving reference %s: could not import module' % ref)
|
||||
|
||||
try:
|
||||
for name in rest.split('.'):
|
||||
for name in modulename.split('.')[1:] + rest.split('.'):
|
||||
obj = getattr(obj, name)
|
||||
return obj
|
||||
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.
|
||||
If it is not a reference, the object is returned as-is.
|
||||
|
||||
"""
|
||||
|
||||
if not isinstance(ref, str):
|
||||
return ref
|
||||
return ref_to_obj(ref)
|
||||
@@ -316,8 +281,7 @@ if six.PY2:
|
||||
return string.encode('ascii', 'backslashreplace')
|
||||
return string
|
||||
else:
|
||||
def repr_escape(string):
|
||||
return string
|
||||
repr_escape = lambda string: string
|
||||
|
||||
|
||||
def check_callable_args(func, args, kwargs):
|
||||
@@ -326,54 +290,70 @@ def check_callable_args(func, args, kwargs):
|
||||
|
||||
:type args: tuple
|
||||
:type kwargs: dict
|
||||
|
||||
"""
|
||||
|
||||
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
|
||||
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
|
||||
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)
|
||||
# indicates if the signature defines *args and **kwargs respectively
|
||||
has_varargs = has_var_kwargs = False
|
||||
unmatched_kwargs = list(kwargs) # kwargs that didn't match any of the parameters in the signature
|
||||
has_varargs = has_var_kwargs = False # indicates if the signature defines *args and **kwargs respectively
|
||||
|
||||
try:
|
||||
if sys.version_info >= (3, 5):
|
||||
sig = signature(func, follow_wrapped=False)
|
||||
else:
|
||||
if signature:
|
||||
try:
|
||||
sig = signature(func)
|
||||
except ValueError:
|
||||
# signature() doesn't work against every kind of callable
|
||||
return
|
||||
except ValueError:
|
||||
return # signature() doesn't work against every kind of callable
|
||||
|
||||
for param in six.itervalues(sig.parameters):
|
||||
if param.kind == param.POSITIONAL_OR_KEYWORD:
|
||||
if param.name in unmatched_kwargs and unmatched_args:
|
||||
pos_kwargs_conflicts.append(param.name)
|
||||
for param in six.itervalues(sig.parameters):
|
||||
if param.kind == param.POSITIONAL_OR_KEYWORD:
|
||||
if param.name in unmatched_kwargs and unmatched_args:
|
||||
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:
|
||||
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
|
||||
elif arg in unmatched_kwargs:
|
||||
unmatched_kwargs.remove(arg)
|
||||
elif default is undefined:
|
||||
unsatisfied_args.append(arg)
|
||||
|
||||
# Make sure there are no conflicts between args and kwargs
|
||||
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' %
|
||||
', '.join(positional_only_kwargs))
|
||||
|
||||
# Check that the number of positional arguments minus the number of matched kwargs matches the
|
||||
# argspec
|
||||
# Check that the number of positional arguments minus the number of matched kwargs matches the argspec
|
||||
if unsatisfied_args:
|
||||
raise ValueError('The following arguments have not been supplied: %s' %
|
||||
', '.join(unsatisfied_args))
|
||||
raise ValueError('The following arguments have not been supplied: %s' % ', '.join(unsatisfied_args))
|
||||
|
||||
# Check that all keyword-only arguments have been supplied
|
||||
if unsatisfied_kwargs:
|
||||
raise ValueError(
|
||||
'The following keyword-only arguments have not been supplied in kwargs: %s' %
|
||||
', '.join(unsatisfied_kwargs))
|
||||
raise ValueError('The following keyword-only arguments have not been supplied in kwargs: %s' %
|
||||
', '.join(unsatisfied_kwargs))
|
||||
|
||||
# Check that the callable can accept the given number of positional arguments
|
||||
if not has_varargs and unmatched_args:
|
||||
raise ValueError(
|
||||
'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)))
|
||||
raise ValueError('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)))
|
||||
|
||||
# Check that the callable can accept the given keyword arguments
|
||||
if not has_var_kwargs and unmatched_kwargs:
|
||||
raise ValueError(
|
||||
'The target callable does not accept the following keyword arguments: %s' %
|
||||
', '.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))
|
||||
raise ValueError('The target callable does not accept the following keyword arguments: %s' %
|
||||
', '.join(unmatched_kwargs))
|
||||
|
||||
@@ -13,29 +13,28 @@
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
|
||||
import confuse
|
||||
from sys import stderr
|
||||
|
||||
import confuse
|
||||
|
||||
__version__ = "2.0.0"
|
||||
__author__ = "Adrian Sampson <adrian@radbox.org>"
|
||||
__version__ = '1.6.0'
|
||||
__author__ = 'Adrian Sampson <adrian@radbox.org>'
|
||||
|
||||
|
||||
class IncludeLazyConfig(confuse.LazyConfig):
|
||||
"""A version of Confuse's LazyConfig that also merges in data from
|
||||
YAML files specified in an `include` setting.
|
||||
"""
|
||||
|
||||
def read(self, user=True, defaults=True):
|
||||
super().read(user, defaults)
|
||||
|
||||
try:
|
||||
for view in self["include"]:
|
||||
for view in self['include']:
|
||||
self.set_file(view.as_filename())
|
||||
except confuse.NotFoundError:
|
||||
pass
|
||||
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__)
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
|
||||
import sys
|
||||
|
||||
from .ui import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+112
-108
@@ -17,19 +17,21 @@ music and items' embedded album art.
|
||||
"""
|
||||
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import platform
|
||||
from tempfile import NamedTemporaryFile
|
||||
import os
|
||||
|
||||
import mediafile
|
||||
|
||||
from beets.util import bytestring_path, displayable_path, syspath
|
||||
from beets.util import displayable_path, syspath, bytestring_path
|
||||
from beets.util.artresizer import ArtResizer
|
||||
import mediafile
|
||||
|
||||
|
||||
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()
|
||||
return mediafile.Image(data, type=mediafile.ImageType.front)
|
||||
|
||||
@@ -39,168 +41,170 @@ def get_art(log, item):
|
||||
try:
|
||||
mf = mediafile.MediaFile(syspath(item.path))
|
||||
except mediafile.UnreadableFileError as exc:
|
||||
log.warning(
|
||||
"Could not extract art from {0}: {1}",
|
||||
displayable_path(item.path),
|
||||
exc,
|
||||
)
|
||||
log.warning('Could not extract art from {0}: {1}',
|
||||
displayable_path(item.path), exc)
|
||||
return
|
||||
|
||||
return mf.art
|
||||
|
||||
|
||||
def embed_item(
|
||||
log,
|
||||
item,
|
||||
imagepath,
|
||||
maxwidth=None,
|
||||
itempath=None,
|
||||
compare_threshold=0,
|
||||
ifempty=False,
|
||||
as_album=False,
|
||||
id3v23=None,
|
||||
quality=0,
|
||||
):
|
||||
"""Embed an image into the item's media file."""
|
||||
# Conditions.
|
||||
def embed_item(log, item, imagepath, maxwidth=None, itempath=None,
|
||||
compare_threshold=0, ifempty=False, as_album=False, id3v23=None,
|
||||
quality=0):
|
||||
"""Embed an image into the item's media file.
|
||||
"""
|
||||
# Conditions and filters.
|
||||
if compare_threshold:
|
||||
is_similar = check_art_similarity(
|
||||
log, item, imagepath, compare_threshold
|
||||
)
|
||||
if is_similar is None:
|
||||
log.warning("Error while checking art similarity; skipping.")
|
||||
if not check_art_similarity(log, item, imagepath, compare_threshold):
|
||||
log.info('Image not similar; skipping.')
|
||||
return
|
||||
elif not is_similar:
|
||||
log.info("Image not similar; skipping.")
|
||||
return
|
||||
|
||||
if ifempty and get_art(log, item):
|
||||
log.info("media file already contained art")
|
||||
log.info('media file already contained art')
|
||||
return
|
||||
|
||||
# Filters.
|
||||
if maxwidth and not as_album:
|
||||
imagepath = resize_image(log, imagepath, maxwidth, quality)
|
||||
|
||||
# Get the `Image` object from the file.
|
||||
try:
|
||||
log.debug("embedding {0}", displayable_path(imagepath))
|
||||
log.debug('embedding {0}', displayable_path(imagepath))
|
||||
image = mediafile_image(imagepath, maxwidth)
|
||||
except OSError as exc:
|
||||
log.warning("could not read image file: {0}", exc)
|
||||
log.warning('could not read image file: {0}', exc)
|
||||
return
|
||||
|
||||
# Make sure the image kind is safe (some formats only support PNG
|
||||
# and JPEG).
|
||||
if image.mime_type not in ("image/jpeg", "image/png"):
|
||||
log.info("not embedding image of unsupported type: {}", image.mime_type)
|
||||
if image.mime_type not in ('image/jpeg', 'image/png'):
|
||||
log.info('not embedding image of unsupported type: {}',
|
||||
image.mime_type)
|
||||
return
|
||||
|
||||
item.try_write(path=itempath, tags={"images": [image]}, id3v23=id3v23)
|
||||
item.try_write(path=itempath, tags={'images': [image]}, id3v23=id3v23)
|
||||
|
||||
|
||||
def embed_album(
|
||||
log,
|
||||
album,
|
||||
maxwidth=None,
|
||||
quiet=False,
|
||||
compare_threshold=0,
|
||||
ifempty=False,
|
||||
quality=0,
|
||||
):
|
||||
"""Embed album art into all of the album's items."""
|
||||
def embed_album(log, album, maxwidth=None, quiet=False, compare_threshold=0,
|
||||
ifempty=False, quality=0):
|
||||
"""Embed album art into all of the album's items.
|
||||
"""
|
||||
imagepath = album.artpath
|
||||
if not imagepath:
|
||||
log.info("No album art present for {0}", album)
|
||||
log.info('No album art present for {0}', album)
|
||||
return
|
||||
if not os.path.isfile(syspath(imagepath)):
|
||||
log.info(
|
||||
"Album art not found at {0} for {1}",
|
||||
displayable_path(imagepath),
|
||||
album,
|
||||
)
|
||||
log.info('Album art not found at {0} for {1}',
|
||||
displayable_path(imagepath), album)
|
||||
return
|
||||
if maxwidth:
|
||||
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():
|
||||
embed_item(
|
||||
log,
|
||||
item,
|
||||
imagepath,
|
||||
maxwidth,
|
||||
None,
|
||||
compare_threshold,
|
||||
ifempty,
|
||||
as_album=True,
|
||||
quality=quality,
|
||||
)
|
||||
embed_item(log, item, imagepath, maxwidth, None, compare_threshold,
|
||||
ifempty, as_album=True, quality=quality)
|
||||
|
||||
|
||||
def resize_image(log, imagepath, maxwidth, quality):
|
||||
"""Returns path to an image resized to maxwidth and encoded with the
|
||||
specified quality level.
|
||||
"""
|
||||
log.debug(
|
||||
"Resizing album art to {0} pixels wide and encoding at quality \
|
||||
level {1}",
|
||||
maxwidth,
|
||||
quality,
|
||||
)
|
||||
imagepath = ArtResizer.shared.resize(
|
||||
maxwidth, syspath(imagepath), quality=quality
|
||||
)
|
||||
log.debug('Resizing album art to {0} pixels wide and encoding at quality \
|
||||
level {1}', maxwidth, quality)
|
||||
imagepath = ArtResizer.shared.resize(maxwidth, syspath(imagepath),
|
||||
quality=quality)
|
||||
return imagepath
|
||||
|
||||
|
||||
def check_art_similarity(
|
||||
log,
|
||||
item,
|
||||
imagepath,
|
||||
compare_threshold,
|
||||
artresizer=None,
|
||||
):
|
||||
def check_art_similarity(log, item, imagepath, compare_threshold):
|
||||
"""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:
|
||||
art = extract(log, f.name, item)
|
||||
|
||||
if not art:
|
||||
return True
|
||||
if art:
|
||||
is_windows = platform.system() == "Windows"
|
||||
|
||||
if artresizer is None:
|
||||
artresizer = ArtResizer.shared
|
||||
# Converting images to grayscale tends to minimize the weight
|
||||
# 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):
|
||||
art = get_art(log, item)
|
||||
outpath = bytestring_path(outpath)
|
||||
if not art:
|
||||
log.info("No album art present in {0}, skipping.", item)
|
||||
log.info('No album art present in {0}, skipping.', item)
|
||||
return
|
||||
|
||||
# Add an extension to the filename.
|
||||
ext = mediafile.image_extension(art)
|
||||
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
|
||||
outpath += bytestring_path("." + ext)
|
||||
outpath += bytestring_path('.' + ext)
|
||||
|
||||
log.info(
|
||||
"Extracting album art from: {0} to: {1}",
|
||||
item,
|
||||
displayable_path(outpath),
|
||||
)
|
||||
with open(syspath(outpath), "wb") as f:
|
||||
log.info('Extracting album art from: {0} to: {1}',
|
||||
item, displayable_path(outpath))
|
||||
with open(syspath(outpath), 'wb') as f:
|
||||
f.write(art)
|
||||
return outpath
|
||||
|
||||
@@ -214,7 +218,7 @@ def extract_first(log, outpath, items):
|
||||
|
||||
def clear(log, lib, 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:
|
||||
log.debug("Clearing art for {0}", item)
|
||||
item.try_write(tags={"images": None})
|
||||
log.debug('Clearing art for {0}', item)
|
||||
item.try_write(tags={'images': None})
|
||||
|
||||
@@ -14,91 +14,78 @@
|
||||
|
||||
"""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.
|
||||
from .hooks import ( # noqa
|
||||
AlbumInfo,
|
||||
AlbumMatch,
|
||||
Distance,
|
||||
TrackInfo,
|
||||
AlbumMatch,
|
||||
TrackMatch,
|
||||
Distance,
|
||||
)
|
||||
from .match import tag_item, tag_album, Proposal # noqa
|
||||
from .match import Recommendation # noqa
|
||||
from .match import Proposal, current_metadata, tag_album, tag_item # noqa
|
||||
|
||||
# Global logger.
|
||||
log = logging.getLogger("beets")
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
# Metadata fields that are already hardcoded, or where the tag name changes.
|
||||
SPECIAL_FIELDS = {
|
||||
"album": (
|
||||
"va",
|
||||
"releasegroup_id",
|
||||
"artist_id",
|
||||
"artists_ids",
|
||||
"album_id",
|
||||
"mediums",
|
||||
"tracks",
|
||||
"year",
|
||||
"month",
|
||||
"day",
|
||||
"artist",
|
||||
"artists",
|
||||
"artist_credit",
|
||||
"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",
|
||||
'album': (
|
||||
'va',
|
||||
'releasegroup_id',
|
||||
'artist_id',
|
||||
'album_id',
|
||||
'mediums',
|
||||
'tracks',
|
||||
'year',
|
||||
'month',
|
||||
'day',
|
||||
'artist',
|
||||
'artist_credit',
|
||||
'artist_sort',
|
||||
'data_url'
|
||||
),
|
||||
'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.
|
||||
|
||||
|
||||
def apply_item_metadata(item: Item, track_info: TrackInfo):
|
||||
"""Set an item's metadata from its matched TrackInfo object."""
|
||||
def apply_item_metadata(item, track_info):
|
||||
"""Set an item's metadata from its matched TrackInfo object.
|
||||
"""
|
||||
item.artist = track_info.artist
|
||||
item.artists = track_info.artists
|
||||
item.artist_sort = track_info.artist_sort
|
||||
item.artists_sort = track_info.artists_sort
|
||||
item.artist_credit = track_info.artist_credit
|
||||
item.artists_credit = track_info.artists_credit
|
||||
item.title = track_info.title
|
||||
item.mb_trackid = track_info.track_id
|
||||
item.mb_releasetrackid = track_info.release_track_id
|
||||
if 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():
|
||||
# We only overwrite fields that are not already hardcoded.
|
||||
if field in SPECIAL_FIELDS["track"]:
|
||||
if field in SPECIAL_FIELDS['track']:
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
@@ -108,62 +95,45 @@ def apply_item_metadata(item: Item, track_info: TrackInfo):
|
||||
# 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
|
||||
mapping from Items to TrackInfo objects.
|
||||
"""
|
||||
for item, track_info in mapping.items():
|
||||
# Artist or artist credit.
|
||||
if config["artist_credit"]:
|
||||
item.artist = (
|
||||
track_info.artist_credit
|
||||
or track_info.artist
|
||||
or 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
|
||||
if config['artist_credit']:
|
||||
item.artist = (track_info.artist_credit or
|
||||
track_info.artist or
|
||||
album_info.artist_credit or
|
||||
album_info.artist)
|
||||
item.albumartist = (album_info.artist_credit or
|
||||
album_info.artist)
|
||||
else:
|
||||
item.artist = track_info.artist or album_info.artist
|
||||
item.artists = track_info.artists or album_info.artists
|
||||
item.artist = (track_info.artist or album_info.artist)
|
||||
item.albumartist = album_info.artist
|
||||
item.albumartists = album_info.artists
|
||||
|
||||
# Album.
|
||||
item.album = album_info.album
|
||||
|
||||
# Artist sort and credit names.
|
||||
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 album_info.artist_credit
|
||||
)
|
||||
item.artists_credit = (
|
||||
track_info.artists_credit or album_info.artists_credit
|
||||
)
|
||||
item.artist_credit = (track_info.artist_credit or
|
||||
album_info.artist_credit)
|
||||
item.albumartist_sort = album_info.artist_sort
|
||||
item.albumartists_sort = album_info.artists_sort
|
||||
item.albumartist_credit = album_info.artist_credit
|
||||
item.albumartists_credit = album_info.artists_credit
|
||||
|
||||
# Release date.
|
||||
for prefix in "", "original_":
|
||||
if config["original_date"] and not prefix:
|
||||
for prefix in '', 'original_':
|
||||
if config['original_date'] and not prefix:
|
||||
# Ignore specific release date.
|
||||
continue
|
||||
|
||||
for suffix in "year", "month", "day":
|
||||
for suffix in 'year', 'month', 'day':
|
||||
key = prefix + suffix
|
||||
value = getattr(album_info, key) or 0
|
||||
|
||||
# If we don't even have a year, apply nothing.
|
||||
if suffix == "year" and not value:
|
||||
if suffix == 'year' and not value:
|
||||
break
|
||||
|
||||
# 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,
|
||||
# also set item.year = info.original_year, etc.
|
||||
if config["original_date"]:
|
||||
if config['original_date']:
|
||||
item[suffix] = value
|
||||
|
||||
# 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
|
||||
# is not provided we need to fall back to the overall index.
|
||||
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
|
||||
else:
|
||||
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_albumartistids = album_info.artists_ids
|
||||
item.mb_releasegroupid = album_info.releasegroup_id
|
||||
|
||||
# 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
|
||||
# field is explicitly allowed to be overwritten
|
||||
for field, value in album_info.items():
|
||||
if field in SPECIAL_FIELDS["album"]:
|
||||
if field in SPECIAL_FIELDS['album']:
|
||||
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:
|
||||
continue
|
||||
item[field] = value
|
||||
|
||||
for field, value in track_info.items():
|
||||
if field in SPECIAL_FIELDS["track"]:
|
||||
if field in SPECIAL_FIELDS['track']:
|
||||
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)
|
||||
if value is None and not clobber:
|
||||
continue
|
||||
|
||||
+184
-242
@@ -14,51 +14,40 @@
|
||||
|
||||
"""Glue between metadata sources and the matching logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import namedtuple
|
||||
from functools import total_ordering
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterable,
|
||||
Iterator,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
import re
|
||||
|
||||
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 unidecode import unidecode
|
||||
|
||||
from beets import config, logging, plugins
|
||||
from beets.autotag import mb
|
||||
from beets.library import Item
|
||||
from beets.util import as_string, cached_classproperty
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
log = logging.getLogger("beets")
|
||||
|
||||
V = TypeVar("V")
|
||||
# The name of the type for patterns in re changed in Python 3.7.
|
||||
try:
|
||||
Pattern = re._pattern_type
|
||||
except AttributeError:
|
||||
Pattern = re.Pattern
|
||||
|
||||
|
||||
# Classes used to represent candidate options.
|
||||
class AttrDict(Dict[str, V]):
|
||||
class AttrDict(dict):
|
||||
"""A dictionary that supports attribute ("dot") access, so `d.field`
|
||||
is equivalent to `d['field']`.
|
||||
"""
|
||||
|
||||
def __getattr__(self, attr: str) -> V:
|
||||
def __getattr__(self, attr):
|
||||
if attr in self:
|
||||
return self[attr]
|
||||
return self.get(attr)
|
||||
else:
|
||||
raise AttributeError
|
||||
|
||||
def __setattr__(self, key: str, value: V):
|
||||
def __setattr__(self, key, value):
|
||||
self.__setitem__(key, value)
|
||||
|
||||
def __hash__(self):
|
||||
@@ -79,73 +68,32 @@ class AlbumInfo(AttrDict):
|
||||
The others are optional and may be None.
|
||||
"""
|
||||
|
||||
# TYPING: are all of these correct? I've assumed optional strings
|
||||
def __init__(
|
||||
self,
|
||||
tracks: List[TrackInfo],
|
||||
album: Optional[str] = None,
|
||||
album_id: Optional[str] = None,
|
||||
artist: Optional[str] = None,
|
||||
artist_id: Optional[str] = None,
|
||||
artists: Optional[List[str]] = None,
|
||||
artists_ids: Optional[List[str]] = None,
|
||||
asin: Optional[str] = None,
|
||||
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,
|
||||
):
|
||||
def __init__(self, tracks, album=None, album_id=None, artist=None,
|
||||
artist_id=None, asin=None, albumtype=None, va=False,
|
||||
year=None, month=None, day=None, label=None, mediums=None,
|
||||
artist_sort=None, releasegroup_id=None, catalognum=None,
|
||||
script=None, language=None, country=None, style=None,
|
||||
genre=None, albumstatus=None, media=None, albumdisambig=None,
|
||||
releasegroupdisambig=None, artist_credit=None,
|
||||
original_year=None, original_month=None,
|
||||
original_day=None, data_source=None, data_url=None,
|
||||
discogs_albumid=None, discogs_labelid=None,
|
||||
discogs_artistid=None, **kwargs):
|
||||
self.album = album
|
||||
self.album_id = album_id
|
||||
self.artist = artist
|
||||
self.artist_id = artist_id
|
||||
self.artists = artists or []
|
||||
self.artists_ids = artists_ids or []
|
||||
self.tracks = tracks
|
||||
self.asin = asin
|
||||
self.albumtype = albumtype
|
||||
self.albumtypes = albumtypes or []
|
||||
self.va = va
|
||||
self.year = year
|
||||
self.month = month
|
||||
self.day = day
|
||||
self.label = label
|
||||
self.barcode = barcode
|
||||
self.mediums = mediums
|
||||
self.artist_sort = artist_sort
|
||||
self.artists_sort = artists_sort or []
|
||||
self.releasegroup_id = releasegroup_id
|
||||
self.release_group_title = release_group_title
|
||||
self.catalognum = catalognum
|
||||
self.script = script
|
||||
self.language = language
|
||||
@@ -157,7 +105,6 @@ class AlbumInfo(AttrDict):
|
||||
self.albumdisambig = albumdisambig
|
||||
self.releasegroupdisambig = releasegroupdisambig
|
||||
self.artist_credit = artist_credit
|
||||
self.artists_credit = artists_credit or []
|
||||
self.original_year = original_year
|
||||
self.original_month = original_month
|
||||
self.original_day = original_day
|
||||
@@ -168,7 +115,27 @@ class AlbumInfo(AttrDict):
|
||||
self.discogs_artistid = discogs_artistid
|
||||
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.update(self)
|
||||
dupe.tracks = [track.copy() for track in self.tracks]
|
||||
@@ -187,50 +154,20 @@ class TrackInfo(AttrDict):
|
||||
are all 1-based.
|
||||
"""
|
||||
|
||||
# TYPING: are all of these correct? I've assumed optional strings
|
||||
def __init__(
|
||||
self,
|
||||
title: Optional[str] = None,
|
||||
track_id: Optional[str] = None,
|
||||
release_track_id: Optional[str] = None,
|
||||
artist: Optional[str] = None,
|
||||
artist_id: Optional[str] = None,
|
||||
artists: Optional[List[str]] = None,
|
||||
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,
|
||||
):
|
||||
def __init__(self, title=None, track_id=None, release_track_id=None,
|
||||
artist=None, artist_id=None, length=None, index=None,
|
||||
medium=None, medium_index=None, medium_total=None,
|
||||
artist_sort=None, disctitle=None, artist_credit=None,
|
||||
data_source=None, data_url=None, media=None, lyricist=None,
|
||||
composer=None, composer_sort=None, arranger=None,
|
||||
track_alt=None, work=None, mb_workid=None,
|
||||
work_disambig=None, bpm=None, initial_key=None, genre=None,
|
||||
**kwargs):
|
||||
self.title = title
|
||||
self.track_id = track_id
|
||||
self.release_track_id = release_track_id
|
||||
self.artist = artist
|
||||
self.artist_id = artist_id
|
||||
self.artists = artists or []
|
||||
self.artists_ids = artists_ids or []
|
||||
self.length = length
|
||||
self.index = index
|
||||
self.media = media
|
||||
@@ -238,10 +175,8 @@ class TrackInfo(AttrDict):
|
||||
self.medium_index = medium_index
|
||||
self.medium_total = medium_total
|
||||
self.artist_sort = artist_sort
|
||||
self.artists_sort = artists_sort or []
|
||||
self.disctitle = disctitle
|
||||
self.artist_credit = artist_credit
|
||||
self.artists_credit = artists_credit or []
|
||||
self.data_source = data_source
|
||||
self.data_url = data_url
|
||||
self.lyricist = lyricist
|
||||
@@ -255,10 +190,20 @@ class TrackInfo(AttrDict):
|
||||
self.bpm = bpm
|
||||
self.initial_key = initial_key
|
||||
self.genre = genre
|
||||
self.album = album
|
||||
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.update(self)
|
||||
return dupe
|
||||
@@ -268,23 +213,23 @@ class TrackInfo(AttrDict):
|
||||
|
||||
# Parameters for string distance function.
|
||||
# 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.
|
||||
SD_PATTERNS = [
|
||||
(r"^the ", 0.1),
|
||||
(r"[\[\(]?(ep|single)[\]\)]?", 0.0),
|
||||
(r"[\[\(]?(featuring|feat|ft)[\. :].+", 0.1),
|
||||
(r"\(.*?\)", 0.3),
|
||||
(r"\[.*?\]", 0.3),
|
||||
(r"(, )?(pt\.|part) .+", 0.2),
|
||||
(r'^the ', 0.1),
|
||||
(r'[\[\(]?(ep|single)[\]\)]?', 0.0),
|
||||
(r'[\[\(]?(featuring|feat|ft)[\. :].+', 0.1),
|
||||
(r'\(.*?\)', 0.3),
|
||||
(r'\[.*?\]', 0.3),
|
||||
(r'(, )?(pt\.|part) .+', 0.2),
|
||||
]
|
||||
# Replacements to use before testing distance.
|
||||
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
|
||||
non-alphanumeric characters and case. Comparisons are based on a
|
||||
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)
|
||||
str1 = as_string(unidecode(str1))
|
||||
str2 = as_string(unidecode(str2))
|
||||
str1 = re.sub(r"[^a-z0-9]", "", str1.lower())
|
||||
str2 = re.sub(r"[^a-z0-9]", "", str2.lower())
|
||||
str1 = re.sub(r'[^a-z0-9]', '', str1.lower())
|
||||
str2 = re.sub(r'[^a-z0-9]', '', str2.lower())
|
||||
if not str1 and not str2:
|
||||
return 0.0
|
||||
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
|
||||
an edit distance, normalized by the string length, with a number of
|
||||
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
|
||||
# "something, the".
|
||||
for word in SD_END_WORDS:
|
||||
if str1.endswith(", %s" % word):
|
||||
str1 = "{} {}".format(word, str1[: -len(word) - 2])
|
||||
if str2.endswith(", %s" % word):
|
||||
str2 = "{} {}".format(word, str2[: -len(word) - 2])
|
||||
if str1.endswith(', %s' % word):
|
||||
str1 = '{} {}'.format(word, str1[:-len(word) - 2])
|
||||
if str2.endswith(', %s' % word):
|
||||
str2 = '{} {}'.format(word, str2[:-len(word) - 2])
|
||||
|
||||
# Perform a couple of basic normalizing substitutions.
|
||||
for pat, repl in SD_REPLACE:
|
||||
@@ -336,8 +281,8 @@ def string_dist(str1: Optional[str], str2: Optional[str]) -> float:
|
||||
penalty = 0.0
|
||||
for pat, weight in SD_PATTERNS:
|
||||
# Get strings that drop the pattern.
|
||||
case_str1 = re.sub(pat, "", str1)
|
||||
case_str2 = re.sub(pat, "", str2)
|
||||
case_str1 = re.sub(pat, '', str1)
|
||||
case_str2 = re.sub(pat, '', str2)
|
||||
|
||||
if case_str1 != str1 or case_str2 != str2:
|
||||
# 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
|
||||
|
||||
|
||||
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
|
||||
class Distance:
|
||||
"""Keeps track of multiple distance penalties. Provides a single
|
||||
@@ -368,12 +330,12 @@ class Distance:
|
||||
|
||||
def __init__(self):
|
||||
self._penalties = {}
|
||||
self.tracks: Dict[TrackInfo, Distance] = {}
|
||||
|
||||
@cached_classproperty
|
||||
def _weights(cls) -> Dict[str, float]: # noqa: N805
|
||||
"""A dictionary from keys to floating-point weights."""
|
||||
weights_view = config["match"]["distance_weights"]
|
||||
@LazyClassProperty
|
||||
def _weights(cls): # noqa: N805
|
||||
"""A dictionary from keys to floating-point weights.
|
||||
"""
|
||||
weights_view = config['match']['distance_weights']
|
||||
weights = {}
|
||||
for key in weights_view.keys():
|
||||
weights[key] = weights_view[key].as_number()
|
||||
@@ -382,7 +344,7 @@ class Distance:
|
||||
# Access the components and their aggregates.
|
||||
|
||||
@property
|
||||
def distance(self) -> float:
|
||||
def distance(self):
|
||||
"""Return a weighted and normalized distance across all
|
||||
penalties.
|
||||
"""
|
||||
@@ -392,22 +354,24 @@ class Distance:
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def max_distance(self) -> float:
|
||||
"""Return the maximum distance penalty (normalization factor)."""
|
||||
def max_distance(self):
|
||||
"""Return the maximum distance penalty (normalization factor).
|
||||
"""
|
||||
dist_max = 0.0
|
||||
for key, penalty in self._penalties.items():
|
||||
dist_max += len(penalty) * self._weights[key]
|
||||
return dist_max
|
||||
|
||||
@property
|
||||
def raw_distance(self) -> float:
|
||||
"""Return the raw (denormalized) distance."""
|
||||
def raw_distance(self):
|
||||
"""Return the raw (denormalized) distance.
|
||||
"""
|
||||
dist_raw = 0.0
|
||||
for key, penalty in self._penalties.items():
|
||||
dist_raw += sum(penalty) * self._weights[key]
|
||||
return dist_raw
|
||||
|
||||
def items(self) -> List[Tuple[str, float]]:
|
||||
def items(self):
|
||||
"""Return a list of (key, dist) pairs, with `dist` being the
|
||||
weighted distance, sorted from highest to lowest. Does not
|
||||
include penalties with a zero value.
|
||||
@@ -421,88 +385,87 @@ class Distance:
|
||||
# ascending order (for keys, when the penalty is equal) and
|
||||
# still get the items with the biggest distance first.
|
||||
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)
|
||||
|
||||
def __eq__(self, other) -> bool:
|
||||
def __eq__(self, other):
|
||||
return self.distance == other
|
||||
|
||||
# Behave like a float.
|
||||
|
||||
def __lt__(self, other) -> bool:
|
||||
def __lt__(self, other):
|
||||
return self.distance < other
|
||||
|
||||
def __float__(self) -> float:
|
||||
def __float__(self):
|
||||
return self.distance
|
||||
|
||||
def __sub__(self, other) -> float:
|
||||
def __sub__(self, other):
|
||||
return self.distance - other
|
||||
|
||||
def __rsub__(self, other) -> float:
|
||||
def __rsub__(self, other):
|
||||
return other - self.distance
|
||||
|
||||
def __str__(self) -> str:
|
||||
def __str__(self):
|
||||
return f"{self.distance:.2f}"
|
||||
|
||||
# Behave like a dict.
|
||||
|
||||
def __getitem__(self, key) -> float:
|
||||
"""Returns the weighted distance for a named penalty."""
|
||||
def __getitem__(self, key):
|
||||
"""Returns the weighted distance for a named penalty.
|
||||
"""
|
||||
dist = sum(self._penalties[key]) * self._weights[key]
|
||||
dist_max = self.max_distance
|
||||
if dist_max:
|
||||
return dist / dist_max
|
||||
return 0.0
|
||||
|
||||
def __iter__(self) -> Iterator[Tuple[str, float]]:
|
||||
def __iter__(self):
|
||||
return iter(self.items())
|
||||
|
||||
def __len__(self) -> int:
|
||||
def __len__(self):
|
||||
return len(self.items())
|
||||
|
||||
def keys(self) -> List[str]:
|
||||
def keys(self):
|
||||
return [key for key, _ in self.items()]
|
||||
|
||||
def update(self, dist: "Distance"):
|
||||
"""Adds all the distance penalties from `dist`."""
|
||||
def update(self, dist):
|
||||
"""Adds all the distance penalties from `dist`.
|
||||
"""
|
||||
if not isinstance(dist, Distance):
|
||||
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():
|
||||
self._penalties.setdefault(key, []).extend(penalties)
|
||||
|
||||
# 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
|
||||
be a compiled regular expression, in which case it will be
|
||||
matched against `value2`.
|
||||
"""
|
||||
if isinstance(value1, re.Pattern):
|
||||
value2 = cast(str, value2)
|
||||
if isinstance(value1, Pattern):
|
||||
return bool(value1.match(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
|
||||
configured weight setting. `dist` must be a float between 0.0
|
||||
and 1.0, and will be added to any existing distance penalties
|
||||
for the same key.
|
||||
"""
|
||||
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)
|
||||
|
||||
def add_equality(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
options: Union[List[Any], Tuple[Any, ...], Any],
|
||||
):
|
||||
def add_equality(self, key, value, options):
|
||||
"""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
|
||||
expression, it will be considered equal if it matches against
|
||||
@@ -518,7 +481,7 @@ class Distance:
|
||||
dist = 1.0
|
||||
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,
|
||||
or 0.0.
|
||||
"""
|
||||
@@ -527,7 +490,7 @@ class Distance:
|
||||
else:
|
||||
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
|
||||
between `number1` and `number2`, or 0.0 when there is no
|
||||
difference. Use this when there is no upper limit on the
|
||||
@@ -540,12 +503,7 @@ class Distance:
|
||||
else:
|
||||
self.add(key, 0.0)
|
||||
|
||||
def add_priority(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
options: Union[List[Any], Tuple[Any, ...], Any],
|
||||
):
|
||||
def add_priority(self, key, value, options):
|
||||
"""Adds a distance penalty that corresponds to the position at
|
||||
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
|
||||
@@ -563,12 +521,7 @@ class Distance:
|
||||
dist = 1.0
|
||||
self.add(key, dist)
|
||||
|
||||
def add_ratio(
|
||||
self,
|
||||
key: str,
|
||||
number1: Union[int, float],
|
||||
number2: Union[int, float],
|
||||
):
|
||||
def add_ratio(self, key, number1, number2):
|
||||
"""Adds a distance penalty for `number1` as a ratio of `number2`.
|
||||
`number1` is bound at 0 and `number2`.
|
||||
"""
|
||||
@@ -579,7 +532,7 @@ class Distance:
|
||||
dist = 0.0
|
||||
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
|
||||
`str1` and `str2`.
|
||||
"""
|
||||
@@ -589,82 +542,64 @@ class Distance:
|
||||
|
||||
# Structures that compose all the information for a candidate match.
|
||||
|
||||
AlbumMatch = namedtuple(
|
||||
"AlbumMatch", ["distance", "info", "mapping", "extra_items", "extra_tracks"]
|
||||
)
|
||||
AlbumMatch = namedtuple('AlbumMatch', ['distance', 'info', 'mapping',
|
||||
'extra_items', 'extra_tracks'])
|
||||
|
||||
TrackMatch = namedtuple("TrackMatch", ["distance", "info"])
|
||||
TrackMatch = namedtuple('TrackMatch', ['distance', 'info'])
|
||||
|
||||
|
||||
# Aggregation of sources.
|
||||
|
||||
|
||||
def album_for_mbid(release_id: str) -> Optional[AlbumInfo]:
|
||||
def album_for_mbid(release_id):
|
||||
"""Get an AlbumInfo object for a MusicBrainz release ID. Return None
|
||||
if the ID is not found.
|
||||
"""
|
||||
try:
|
||||
album = mb.album_for_id(release_id)
|
||||
if album:
|
||||
plugins.send("albuminfo_received", info=album)
|
||||
plugins.send('albuminfo_received', info=album)
|
||||
return album
|
||||
except mb.MusicBrainzAPIError as exc:
|
||||
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
|
||||
if the ID is not found.
|
||||
"""
|
||||
try:
|
||||
track = mb.track_for_id(recording_id)
|
||||
if track:
|
||||
plugins.send("trackinfo_received", info=track)
|
||||
plugins.send('trackinfo_received', info=track)
|
||||
return track
|
||||
except mb.MusicBrainzAPIError as exc:
|
||||
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."""
|
||||
a = album_for_mbid(album_id)
|
||||
if a:
|
||||
yield a
|
||||
for a in plugins.album_for_id(album_id):
|
||||
if a:
|
||||
plugins.send("albuminfo_received", info=a)
|
||||
plugins.send('albuminfo_received', info=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."""
|
||||
t = track_for_mbid(track_id)
|
||||
if t:
|
||||
yield t
|
||||
for t in plugins.track_for_id(track_id):
|
||||
if t:
|
||||
plugins.send("trackinfo_received", info=t)
|
||||
plugins.send('trackinfo_received', info=t)
|
||||
yield t
|
||||
|
||||
|
||||
def invoke_mb(call_func: Callable, *args):
|
||||
try:
|
||||
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]:
|
||||
@plugins.notify_info_yielded('albuminfo_received')
|
||||
def album_candidates(items, artist, album, va_likely, extra_tags):
|
||||
"""Search for album matches. ``items`` is a list of Item objects
|
||||
that make up the album. ``artist`` and ``album`` are the respective
|
||||
names (strings), which may be derived from the item list or may be
|
||||
@@ -674,33 +609,40 @@ def album_candidates(
|
||||
constrain the search.
|
||||
"""
|
||||
|
||||
if config["musicbrainz"]["enabled"]:
|
||||
# Base candidates if we have album and artist to match.
|
||||
if artist and album:
|
||||
yield from invoke_mb(
|
||||
mb.match_album, artist, album, len(items), extra_tags
|
||||
)
|
||||
# Base candidates if we have album and artist to match.
|
||||
if artist and album:
|
||||
try:
|
||||
yield from mb.match_album(artist, album, len(items),
|
||||
extra_tags)
|
||||
except mb.MusicBrainzAPIError as exc:
|
||||
exc.log(log)
|
||||
|
||||
# Also add VA matches from MusicBrainz where appropriate.
|
||||
if va_likely and album:
|
||||
yield from invoke_mb(
|
||||
mb.match_album, None, album, len(items), extra_tags
|
||||
)
|
||||
# Also add VA matches from MusicBrainz where appropriate.
|
||||
if va_likely and album:
|
||||
try:
|
||||
yield from mb.match_album(None, album, len(items),
|
||||
extra_tags)
|
||||
except mb.MusicBrainzAPIError as exc:
|
||||
exc.log(log)
|
||||
|
||||
# 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")
|
||||
def item_candidates(item: Item, artist: str, title: str) -> Iterable[Tuple]:
|
||||
@plugins.notify_info_yielded('trackinfo_received')
|
||||
def item_candidates(item, artist, title):
|
||||
"""Search for item matches. ``item`` is the Item to be matched.
|
||||
``artist`` and ``title`` are strings and either reflect the item or
|
||||
are specified by the user.
|
||||
"""
|
||||
|
||||
# MusicBrainz candidates.
|
||||
if config["musicbrainz"]["enabled"] and artist and title:
|
||||
yield from invoke_mb(mb.match_track, artist, title)
|
||||
if artist and title:
|
||||
try:
|
||||
yield from mb.match_track(artist, title)
|
||||
except mb.MusicBrainzAPIError as exc:
|
||||
exc.log(log)
|
||||
|
||||
# Plugin candidates.
|
||||
yield from plugins.item_candidates(item, artist, title)
|
||||
|
||||
+149
-245
@@ -19,53 +19,32 @@ releases and tracks.
|
||||
|
||||
import datetime
|
||||
import re
|
||||
from collections import namedtuple
|
||||
from typing import (
|
||||
Any,
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
from munkres import Munkres
|
||||
from collections import namedtuple
|
||||
|
||||
from beets import config, logging, plugins
|
||||
from beets.autotag import (
|
||||
AlbumInfo,
|
||||
AlbumMatch,
|
||||
Distance,
|
||||
TrackInfo,
|
||||
TrackMatch,
|
||||
hooks,
|
||||
)
|
||||
from beets.library import Item
|
||||
from beets import logging
|
||||
from beets import plugins
|
||||
from beets import config
|
||||
from beets.util import plurality
|
||||
from beets.autotag import hooks
|
||||
from beets.util.enumeration import OrderedEnum
|
||||
|
||||
# Artist signals that indicate "various artists". These are used at the
|
||||
# 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
|
||||
# differing artists.
|
||||
VA_ARTISTS = ("", "various artists", "various", "va", "unknown")
|
||||
VA_ARTISTS = ('', 'various artists', 'various', 'va', 'unknown')
|
||||
|
||||
# Global logger.
|
||||
log = logging.getLogger("beets")
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
|
||||
# Recommendation enumeration.
|
||||
|
||||
|
||||
class Recommendation(OrderedEnum):
|
||||
"""Indicates a qualitative suggestion to the user about what should
|
||||
be done with a given match.
|
||||
"""
|
||||
|
||||
none = 0
|
||||
low = 1
|
||||
medium = 2
|
||||
@@ -76,15 +55,12 @@ class Recommendation(OrderedEnum):
|
||||
# consists of a list of possible candidates (i.e., AlbumInfo or TrackInfo
|
||||
# objects) and a recommendation value.
|
||||
|
||||
Proposal = namedtuple("Proposal", ("candidates", "recommendation"))
|
||||
Proposal = namedtuple('Proposal', ('candidates', 'recommendation'))
|
||||
|
||||
|
||||
# Primary matching functionality.
|
||||
|
||||
|
||||
def current_metadata(
|
||||
items: Iterable[Item],
|
||||
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
def current_metadata(items):
|
||||
"""Extract the likely current metadata for an album given a list of its
|
||||
items. Return two dictionaries:
|
||||
- The most common value for each field.
|
||||
@@ -94,36 +70,22 @@ def current_metadata(
|
||||
|
||||
likelies = {}
|
||||
consensus = {}
|
||||
fields = [
|
||||
"artist",
|
||||
"album",
|
||||
"albumartist",
|
||||
"year",
|
||||
"disctotal",
|
||||
"mb_albumid",
|
||||
"label",
|
||||
"barcode",
|
||||
"catalognum",
|
||||
"country",
|
||||
"media",
|
||||
"albumdisambig",
|
||||
]
|
||||
fields = ['artist', 'album', 'albumartist', 'year', 'disctotal',
|
||||
'mb_albumid', 'label', 'catalognum', 'country', 'media',
|
||||
'albumdisambig']
|
||||
for field in fields:
|
||||
values = [item[field] for item in items if item]
|
||||
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 consensus["albumartist"] and likelies["albumartist"]:
|
||||
likelies["artist"] = likelies["albumartist"]
|
||||
if consensus['albumartist'] and likelies['albumartist']:
|
||||
likelies['artist'] = likelies['albumartist']
|
||||
|
||||
return likelies, consensus
|
||||
|
||||
|
||||
def assign_items(
|
||||
items: Sequence[Item],
|
||||
tracks: Sequence[TrackInfo],
|
||||
) -> Tuple[Dict[Item, TrackInfo], List[Item], List[TrackInfo]]:
|
||||
def assign_items(items, tracks):
|
||||
"""Given a list of Items and a list of TrackInfo objects, find the
|
||||
best mapping between them. Returns a mapping from Items to 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.
|
||||
"""
|
||||
# Construct the cost matrix.
|
||||
costs: List[List[Distance]] = []
|
||||
costs = []
|
||||
for item in items:
|
||||
row = []
|
||||
for track in tracks:
|
||||
for i, track in enumerate(tracks):
|
||||
row.append(track_distance(item, track))
|
||||
costs.append(row)
|
||||
|
||||
# Find a minimum-cost bipartite matching.
|
||||
log.debug("Computing track assignment...")
|
||||
log.debug('Computing track assignment...')
|
||||
matching = Munkres().compute(costs)
|
||||
log.debug("...done.")
|
||||
log.debug('...done.')
|
||||
|
||||
# Produce the output matching.
|
||||
mapping = {items[i]: tracks[j] for (i, j) in matching}
|
||||
@@ -152,18 +114,14 @@ def assign_items(
|
||||
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
|
||||
per disc and per release numbering.
|
||||
"""
|
||||
return item.track not in (track_info.medium_index, track_info.index)
|
||||
|
||||
|
||||
def track_distance(
|
||||
item: Item,
|
||||
track_info: TrackInfo,
|
||||
incl_artist: bool = False,
|
||||
) -> Distance:
|
||||
def track_distance(item, track_info, incl_artist=False):
|
||||
"""Determines the significance of a track metadata change. Returns a
|
||||
Distance object. `incl_artist` indicates that a distance component should
|
||||
be included for the track artist (i.e., for various-artist releases).
|
||||
@@ -172,37 +130,26 @@ def track_distance(
|
||||
|
||||
# Length.
|
||||
if track_info.length:
|
||||
item_length = cast(float, item.length)
|
||||
track_length_grace = cast(
|
||||
Union[float, int],
|
||||
config["match"]["track_length_grace"].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)
|
||||
diff = abs(item.length - track_info.length) - \
|
||||
config['match']['track_length_grace'].as_number()
|
||||
dist.add_ratio('track_length', diff,
|
||||
config['match']['track_length_max'].as_number())
|
||||
|
||||
# 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.
|
||||
if (
|
||||
incl_artist
|
||||
and track_info.artist
|
||||
and item.artist.lower() not in VA_ARTISTS
|
||||
):
|
||||
dist.add_string("track_artist", item.artist, track_info.artist)
|
||||
if incl_artist and track_info.artist and \
|
||||
item.artist.lower() not in VA_ARTISTS:
|
||||
dist.add_string('track_artist', item.artist, track_info.artist)
|
||||
|
||||
# Track index.
|
||||
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.
|
||||
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.
|
||||
dist.update(plugins.track_distance(item, track_info))
|
||||
@@ -210,11 +157,7 @@ def track_distance(
|
||||
return dist
|
||||
|
||||
|
||||
def distance(
|
||||
items: Sequence[Item],
|
||||
album_info: AlbumInfo,
|
||||
mapping: Dict[Item, TrackInfo],
|
||||
) -> Distance:
|
||||
def distance(items, album_info, mapping):
|
||||
"""Determines how "significant" an album metadata change would be.
|
||||
Returns a Distance object. `album_info` is an AlbumInfo object
|
||||
reflecting the album to be compared. `items` is a sequence of all
|
||||
@@ -229,96 +172,90 @@ def distance(
|
||||
|
||||
# Artist, if not various.
|
||||
if not album_info.va:
|
||||
dist.add_string("artist", likelies["artist"], album_info.artist)
|
||||
dist.add_string('artist', likelies['artist'], album_info.artist)
|
||||
|
||||
# Album.
|
||||
dist.add_string("album", likelies["album"], album_info.album)
|
||||
dist.add_string('album', likelies['album'], album_info.album)
|
||||
|
||||
# Current or preferred media.
|
||||
if album_info.media:
|
||||
# Preferred media options.
|
||||
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]
|
||||
patterns = config['match']['preferred']['media'].as_str_seq()
|
||||
options = [re.compile(r'(\d+x)?(%s)' % pat, re.I) for pat in patterns]
|
||||
if options:
|
||||
dist.add_priority("media", album_info.media, options)
|
||||
dist.add_priority('media', album_info.media, options)
|
||||
# Current media.
|
||||
elif likelies["media"]:
|
||||
dist.add_equality("media", album_info.media, likelies["media"])
|
||||
elif likelies['media']:
|
||||
dist.add_equality('media', album_info.media, likelies['media'])
|
||||
|
||||
# Mediums.
|
||||
if likelies["disctotal"] and album_info.mediums:
|
||||
dist.add_number("mediums", likelies["disctotal"], album_info.mediums)
|
||||
if likelies['disctotal'] and album_info.mediums:
|
||||
dist.add_number('mediums', likelies['disctotal'], album_info.mediums)
|
||||
|
||||
# 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
|
||||
# original year.
|
||||
original = album_info.original_year or 1889
|
||||
diff = abs(album_info.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.
|
||||
elif likelies["year"] and album_info.year:
|
||||
if likelies["year"] in (album_info.year, album_info.original_year):
|
||||
elif likelies['year'] and album_info.year:
|
||||
if likelies['year'] in (album_info.year, album_info.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:
|
||||
# Prefer matchest closest to the release year.
|
||||
diff = abs(likelies["year"] - album_info.year)
|
||||
diff_max = abs(
|
||||
datetime.date.today().year - album_info.original_year
|
||||
)
|
||||
dist.add_ratio("year", diff, diff_max)
|
||||
diff = abs(likelies['year'] - album_info.year)
|
||||
diff_max = abs(datetime.date.today().year -
|
||||
album_info.original_year)
|
||||
dist.add_ratio('year', diff, diff_max)
|
||||
else:
|
||||
# Full penalty when there is no original year.
|
||||
dist.add("year", 1.0)
|
||||
dist.add('year', 1.0)
|
||||
|
||||
# Preferred countries.
|
||||
patterns = config["match"]["preferred"]["countries"].as_str_seq()
|
||||
patterns = cast(Sequence[str], patterns)
|
||||
patterns = config['match']['preferred']['countries'].as_str_seq()
|
||||
options = [re.compile(pat, re.I) for pat in patterns]
|
||||
if album_info.country and options:
|
||||
dist.add_priority("country", album_info.country, options)
|
||||
dist.add_priority('country', album_info.country, options)
|
||||
# Country.
|
||||
elif likelies["country"] and album_info.country:
|
||||
dist.add_string("country", likelies["country"], album_info.country)
|
||||
elif likelies['country'] and album_info.country:
|
||||
dist.add_string('country', likelies['country'], album_info.country)
|
||||
|
||||
# Label.
|
||||
if likelies["label"] and album_info.label:
|
||||
dist.add_string("label", likelies["label"], album_info.label)
|
||||
if likelies['label'] and album_info.label:
|
||||
dist.add_string('label', likelies['label'], album_info.label)
|
||||
|
||||
# Catalog number.
|
||||
if likelies["catalognum"] and album_info.catalognum:
|
||||
dist.add_string(
|
||||
"catalognum", likelies["catalognum"], album_info.catalognum
|
||||
)
|
||||
if likelies['catalognum'] and album_info.catalognum:
|
||||
dist.add_string('catalognum', likelies['catalognum'],
|
||||
album_info.catalognum)
|
||||
|
||||
# Disambiguation.
|
||||
if likelies["albumdisambig"] and album_info.albumdisambig:
|
||||
dist.add_string(
|
||||
"albumdisambig", likelies["albumdisambig"], album_info.albumdisambig
|
||||
)
|
||||
if likelies['albumdisambig'] and album_info.albumdisambig:
|
||||
dist.add_string('albumdisambig', likelies['albumdisambig'],
|
||||
album_info.albumdisambig)
|
||||
|
||||
# Album ID.
|
||||
if likelies["mb_albumid"]:
|
||||
dist.add_equality(
|
||||
"album_id", likelies["mb_albumid"], album_info.album_id
|
||||
)
|
||||
if likelies['mb_albumid']:
|
||||
dist.add_equality('album_id', likelies['mb_albumid'],
|
||||
album_info.album_id)
|
||||
|
||||
# Tracks.
|
||||
dist.tracks = {}
|
||||
for item, track in mapping.items():
|
||||
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.
|
||||
for _ in range(len(album_info.tracks) - len(mapping)):
|
||||
dist.add("missing_tracks", 1.0)
|
||||
for i in range(len(album_info.tracks) - len(mapping)):
|
||||
dist.add('missing_tracks', 1.0)
|
||||
|
||||
# Unmatched tracks.
|
||||
for _ in range(len(items) - len(mapping)):
|
||||
dist.add("unmatched_tracks", 1.0)
|
||||
for i in range(len(items) - len(mapping)):
|
||||
dist.add('unmatched_tracks', 1.0)
|
||||
|
||||
# Plugins.
|
||||
dist.update(plugins.album_distance(items, album_info, mapping))
|
||||
@@ -326,7 +263,7 @@ def distance(
|
||||
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
|
||||
AlbumInfo object for the corresponding album. Otherwise, returns
|
||||
None.
|
||||
@@ -337,22 +274,20 @@ def match_by_id(items: Iterable[Item]):
|
||||
try:
|
||||
first = next(albumids)
|
||||
except StopIteration:
|
||||
log.debug("No album ID found.")
|
||||
log.debug('No album ID found.')
|
||||
return None
|
||||
|
||||
# Is there a consensus on the MB album ID?
|
||||
for other in albumids:
|
||||
if other != first:
|
||||
log.debug("No album ID consensus.")
|
||||
log.debug('No album ID consensus.')
|
||||
return None
|
||||
# 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)
|
||||
|
||||
|
||||
def _recommendation(
|
||||
results: Sequence[Union[AlbumMatch, TrackMatch]],
|
||||
) -> Recommendation:
|
||||
def _recommendation(results):
|
||||
"""Given a sorted list of AlbumMatch or TrackMatch objects, return a
|
||||
recommendation based on the results' distances.
|
||||
|
||||
@@ -366,19 +301,17 @@ def _recommendation(
|
||||
|
||||
# Basic distance thresholding.
|
||||
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.
|
||||
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.
|
||||
rec = Recommendation.medium
|
||||
elif len(results) == 1:
|
||||
# Only a single candidate.
|
||||
rec = Recommendation.low
|
||||
elif (
|
||||
results[1].distance - min_dist
|
||||
>= config["match"]["rec_gap_thresh"].as_number()
|
||||
):
|
||||
elif results[1].distance - min_dist >= \
|
||||
config['match']['rec_gap_thresh'].as_number():
|
||||
# Gap between first two candidates is large.
|
||||
rec = Recommendation.low
|
||||
else:
|
||||
@@ -391,60 +324,48 @@ def _recommendation(
|
||||
if isinstance(results[0], hooks.AlbumMatch):
|
||||
for track_dist in min_dist.tracks.values():
|
||||
keys.update(list(track_dist.keys()))
|
||||
max_rec_view = config["match"]["max_rec"]
|
||||
max_rec_view = config['match']['max_rec']
|
||||
for key in keys:
|
||||
if key in list(max_rec_view.keys()):
|
||||
max_rec = max_rec_view[key].as_choice(
|
||||
{
|
||||
"strong": Recommendation.strong,
|
||||
"medium": Recommendation.medium,
|
||||
"low": Recommendation.low,
|
||||
"none": Recommendation.none,
|
||||
}
|
||||
)
|
||||
max_rec = max_rec_view[key].as_choice({
|
||||
'strong': Recommendation.strong,
|
||||
'medium': Recommendation.medium,
|
||||
'low': Recommendation.low,
|
||||
'none': Recommendation.none,
|
||||
})
|
||||
rec = min(rec, max_rec)
|
||||
|
||||
return rec
|
||||
|
||||
|
||||
AnyMatch = TypeVar("AnyMatch", TrackMatch, AlbumMatch)
|
||||
|
||||
|
||||
def _sort_candidates(candidates: Iterable[AnyMatch]) -> Sequence[AnyMatch]:
|
||||
def _sort_candidates(candidates):
|
||||
"""Sort candidates by distance."""
|
||||
return sorted(candidates, key=lambda match: match.distance)
|
||||
|
||||
|
||||
def _add_candidate(
|
||||
items: Sequence[Item],
|
||||
results: Dict[Any, AlbumMatch],
|
||||
info: AlbumInfo,
|
||||
):
|
||||
def _add_candidate(items, results, info):
|
||||
"""Given a candidate AlbumInfo object, attempt to add the candidate
|
||||
to the output dictionary of AlbumMatch objects. This involves
|
||||
checking the track count, ordering the items, checking for
|
||||
duplicates, and calculating the distance.
|
||||
"""
|
||||
log.debug(
|
||||
"Candidate: {0} - {1} ({2})", info.artist, info.album, info.album_id
|
||||
)
|
||||
log.debug('Candidate: {0} - {1} ({2})',
|
||||
info.artist, info.album, info.album_id)
|
||||
|
||||
# Discard albums with zero tracks.
|
||||
if not info.tracks:
|
||||
log.debug("No tracks.")
|
||||
log.debug('No tracks.')
|
||||
return
|
||||
|
||||
# Prevent duplicates.
|
||||
if info.album_id and info.album_id in results:
|
||||
log.debug("Duplicate.")
|
||||
# Don't duplicate.
|
||||
if info.album_id in results:
|
||||
log.debug('Duplicate.')
|
||||
return
|
||||
|
||||
# Discard matches without required tags.
|
||||
for req_tag in cast(
|
||||
Sequence[str], config["match"]["required"].as_str_seq()
|
||||
):
|
||||
for req_tag in config['match']['required'].as_str_seq():
|
||||
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
|
||||
|
||||
# Find mapping between the items and the track info.
|
||||
@@ -455,24 +376,18 @@ def _add_candidate(
|
||||
|
||||
# Skip matches with ignored penalties.
|
||||
penalties = [key for key, _ in dist]
|
||||
ignored = cast(Sequence[str], config["match"]["ignored"].as_str_seq())
|
||||
for penalty in ignored:
|
||||
for penalty in config['match']['ignored'].as_str_seq():
|
||||
if penalty in penalties:
|
||||
log.debug("Ignored. Penalty: {0}", penalty)
|
||||
log.debug('Ignored. Penalty: {0}', penalty)
|
||||
return
|
||||
|
||||
log.debug("Success. Distance: {0}", dist)
|
||||
results[info.album_id] = hooks.AlbumMatch(
|
||||
dist, info, mapping, extra_items, extra_tracks
|
||||
)
|
||||
log.debug('Success. Distance: {0}', dist)
|
||||
results[info.album_id] = hooks.AlbumMatch(dist, info, mapping,
|
||||
extra_items, extra_tracks)
|
||||
|
||||
|
||||
def tag_album(
|
||||
items,
|
||||
search_artist: Optional[str] = None,
|
||||
search_album: Optional[str] = None,
|
||||
search_ids: List[str] = [],
|
||||
) -> Tuple[str, str, Proposal]:
|
||||
def tag_album(items, search_artist=None, search_album=None,
|
||||
search_ids=[]):
|
||||
"""Return a tuple of the current artist name, the current album
|
||||
name, and a `Proposal` containing `AlbumMatch` candidates.
|
||||
|
||||
@@ -492,19 +407,20 @@ def tag_album(
|
||||
"""
|
||||
# Get current metadata.
|
||||
likelies, consensus = current_metadata(items)
|
||||
cur_artist = cast(str, likelies["artist"])
|
||||
cur_album = cast(str, likelies["album"])
|
||||
log.debug("Tagging {0} - {1}", cur_artist, cur_album)
|
||||
cur_artist = likelies['artist']
|
||||
cur_album = likelies['album']
|
||||
log.debug('Tagging {0} - {1}', cur_artist, cur_album)
|
||||
|
||||
# The output result, keys are the MB album ID.
|
||||
candidates: Dict[Any, AlbumMatch] = {}
|
||||
# The output result (distance, AlbumInfo) tuples (keyed by MB album
|
||||
# ID).
|
||||
candidates = {}
|
||||
|
||||
# Search by explicit ID.
|
||||
if search_ids:
|
||||
for search_id in search_ids:
|
||||
log.debug("Searching for album ID: {0}", search_id)
|
||||
for album_info_for_id in hooks.albums_for_id(search_id):
|
||||
_add_candidate(items, candidates, album_info_for_id)
|
||||
log.debug('Searching for album ID: {0}', search_id)
|
||||
for id_candidate in hooks.albums_for_id(search_id):
|
||||
_add_candidate(items, candidates, id_candidate)
|
||||
|
||||
# Use existing metadata or text search.
|
||||
else:
|
||||
@@ -513,58 +429,51 @@ def tag_album(
|
||||
if id_info:
|
||||
_add_candidate(items, candidates, id_info)
|
||||
rec = _recommendation(list(candidates.values()))
|
||||
log.debug("Album ID match recommendation is {0}", rec)
|
||||
if candidates and not config["import"]["timid"]:
|
||||
log.debug('Album ID match recommendation is {0}', rec)
|
||||
if candidates and not config['import']['timid']:
|
||||
# If we have a very good MBID match, return immediately.
|
||||
# Otherwise, this match will compete against metadata-based
|
||||
# matches.
|
||||
if rec == Recommendation.strong:
|
||||
log.debug("ID match.")
|
||||
return (
|
||||
cur_artist,
|
||||
cur_album,
|
||||
Proposal(list(candidates.values()), rec),
|
||||
)
|
||||
log.debug('ID match.')
|
||||
return cur_artist, cur_album, \
|
||||
Proposal(list(candidates.values()), rec)
|
||||
|
||||
# Search terms.
|
||||
if not (search_artist and search_album):
|
||||
# No explicit search terms -- use current metadata.
|
||||
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
|
||||
if config["musicbrainz"]["extra_tags"]:
|
||||
tag_list = config["musicbrainz"]["extra_tags"].get()
|
||||
if config['musicbrainz']['extra_tags']:
|
||||
tag_list = config['musicbrainz']['extra_tags'].get()
|
||||
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?
|
||||
va_likely = (
|
||||
(not consensus["artist"])
|
||||
or (search_artist.lower() in VA_ARTISTS)
|
||||
or any(item.comp for item in items)
|
||||
)
|
||||
log.debug("Album might be VA: {0}", va_likely)
|
||||
va_likely = ((not consensus['artist']) or
|
||||
(search_artist.lower() in VA_ARTISTS) or
|
||||
any(item.comp for item in items))
|
||||
log.debug('Album might be VA: {0}', va_likely)
|
||||
|
||||
# Get the results from the data sources.
|
||||
for matched_candidate in hooks.album_candidates(
|
||||
items, search_artist, search_album, va_likely, extra_tags
|
||||
):
|
||||
for matched_candidate in hooks.album_candidates(items,
|
||||
search_artist,
|
||||
search_album,
|
||||
va_likely,
|
||||
extra_tags):
|
||||
_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.
|
||||
candidates_sorted = _sort_candidates(candidates.values())
|
||||
rec = _recommendation(candidates_sorted)
|
||||
return cur_artist, cur_album, Proposal(candidates_sorted, rec)
|
||||
candidates = _sort_candidates(candidates.values())
|
||||
rec = _recommendation(candidates)
|
||||
return cur_artist, cur_album, Proposal(candidates, rec)
|
||||
|
||||
|
||||
def tag_item(
|
||||
item,
|
||||
search_artist: Optional[str] = None,
|
||||
search_title: Optional[str] = None,
|
||||
search_ids: Optional[List[str]] = None,
|
||||
) -> Proposal:
|
||||
def tag_item(item, search_artist=None, search_title=None,
|
||||
search_ids=[]):
|
||||
"""Find metadata for a single track. Return a `Proposal` consisting
|
||||
of `TrackMatch` objects.
|
||||
|
||||
@@ -576,31 +485,26 @@ def tag_item(
|
||||
# Holds candidates found so far: keys are MBIDs; values are
|
||||
# (distance, TrackInfo) pairs.
|
||||
candidates = {}
|
||||
rec: Optional[Recommendation] = None
|
||||
|
||||
# First, try matching by MusicBrainz ID.
|
||||
trackids = search_ids or [t for t in [item.mb_trackid] if t]
|
||||
if 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):
|
||||
dist = track_distance(item, track_info, incl_artist=True)
|
||||
candidates[track_info.track_id] = hooks.TrackMatch(
|
||||
dist, track_info
|
||||
)
|
||||
candidates[track_info.track_id] = \
|
||||
hooks.TrackMatch(dist, track_info)
|
||||
# If this is a good match, then don't keep searching.
|
||||
rec = _recommendation(_sort_candidates(candidates.values()))
|
||||
if (
|
||||
rec == Recommendation.strong
|
||||
and not config["import"]["timid"]
|
||||
):
|
||||
log.debug("Track ID match.")
|
||||
if rec == Recommendation.strong and \
|
||||
not config['import']['timid']:
|
||||
log.debug('Track ID match.')
|
||||
return Proposal(_sort_candidates(candidates.values()), rec)
|
||||
|
||||
# If we're searching by ID, don't proceed.
|
||||
if search_ids:
|
||||
if candidates:
|
||||
assert rec is not None
|
||||
return Proposal(_sort_candidates(candidates.values()), rec)
|
||||
else:
|
||||
return Proposal([], Recommendation.none)
|
||||
@@ -608,7 +512,7 @@ def tag_item(
|
||||
# Search terms.
|
||||
if not (search_artist and search_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.
|
||||
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)
|
||||
|
||||
# Sort by distance and return with recommendation.
|
||||
log.debug("Found {0} candidates.", len(candidates))
|
||||
candidates_sorted = _sort_candidates(candidates.values())
|
||||
rec = _recommendation(candidates_sorted)
|
||||
return Proposal(candidates_sorted, rec)
|
||||
log.debug('Found {0} candidates.', len(candidates))
|
||||
candidates = _sort_candidates(candidates.values())
|
||||
rec = _recommendation(candidates)
|
||||
return Proposal(candidates, rec)
|
||||
|
||||
+252
-557
File diff suppressed because it is too large
Load Diff
+41
-114
@@ -1,34 +1,10 @@
|
||||
# --------------- Main ---------------
|
||||
|
||||
library: library.db
|
||||
directory: ~/Music
|
||||
statefile: state.pickle
|
||||
|
||||
# --------------- Plugins ---------------
|
||||
|
||||
plugins: []
|
||||
pluginpath: []
|
||||
|
||||
# --------------- Import ---------------
|
||||
|
||||
clutter: ["Thumbs.DB", ".DS_Store"]
|
||||
ignore: [".*", "*~", "System Volume Information", "lost+found"]
|
||||
ignore_hidden: yes
|
||||
|
||||
import:
|
||||
# common options
|
||||
write: yes
|
||||
copy: yes
|
||||
move: no
|
||||
timid: no
|
||||
quiet: no
|
||||
log:
|
||||
# other options
|
||||
default_action: apply
|
||||
languages: []
|
||||
quiet_fallback: skip
|
||||
none_rec_action: ask
|
||||
# rare options
|
||||
link: no
|
||||
hardlink: no
|
||||
reflink: no
|
||||
@@ -37,117 +13,76 @@ import:
|
||||
incremental: no
|
||||
incremental_skip_later: no
|
||||
from_scratch: no
|
||||
quiet_fallback: skip
|
||||
none_rec_action: ask
|
||||
timid: no
|
||||
log:
|
||||
autotag: yes
|
||||
quiet: no
|
||||
singletons: no
|
||||
default_action: apply
|
||||
languages: []
|
||||
detail: no
|
||||
flat: no
|
||||
group_albums: no
|
||||
pretend: false
|
||||
search_ids: []
|
||||
duplicate_keys:
|
||||
album: albumartist album
|
||||
item: artist title
|
||||
duplicate_action: ask
|
||||
duplicate_verbose_prompt: no
|
||||
bell: no
|
||||
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: _
|
||||
drive_sep_replace: _
|
||||
asciify_paths: false
|
||||
art_filename: cover
|
||||
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:
|
||||
keys: albumartist album
|
||||
disambiguators: albumtype year label catalognum albumdisambig releasegroupdisambig
|
||||
bracket: '[]'
|
||||
|
||||
sunique:
|
||||
keys: artist title
|
||||
disambiguators: year trackdisambig
|
||||
bracket: '[]'
|
||||
|
||||
# --------------- Tagging ---------------
|
||||
overwrite_null:
|
||||
album: []
|
||||
track: []
|
||||
|
||||
plugins: []
|
||||
pluginpath: []
|
||||
threaded: yes
|
||||
timeout: 5.0
|
||||
per_disc_numbering: no
|
||||
verbose: 0
|
||||
terminal_encoding:
|
||||
original_date: no
|
||||
artist_credit: no
|
||||
id3v23: no
|
||||
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:
|
||||
terminal_width: 80
|
||||
length_diff_thresh: 10.0
|
||||
color: yes
|
||||
colors:
|
||||
text_success: ['bold', 'green']
|
||||
text_warning: ['bold', 'yellow']
|
||||
text_error: ['bold', 'red']
|
||||
text_highlight: ['bold', 'red']
|
||||
text_highlight_minor: ['white']
|
||||
action_default: ['bold', 'cyan']
|
||||
action: ['bold', 'cyan']
|
||||
# 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 ---------------
|
||||
text_success: green
|
||||
text_warning: yellow
|
||||
text_error: red
|
||||
text_highlight: red
|
||||
text_highlight_minor: lightgray
|
||||
action_default: turquoise
|
||||
action: blue
|
||||
|
||||
format_item: $artist - $album - $title
|
||||
format_album: $albumartist - $album
|
||||
@@ -158,13 +93,14 @@ sort_album: albumartist+ album+
|
||||
sort_item: artist+ album+ disc+ track+
|
||||
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:
|
||||
enabled: yes
|
||||
host: musicbrainz.org
|
||||
https: no
|
||||
ratelimit: 1
|
||||
@@ -172,13 +108,6 @@ musicbrainz:
|
||||
searchlimit: 5
|
||||
extra_tags: []
|
||||
genres: no
|
||||
external_ids:
|
||||
discogs: no
|
||||
bandcamp: no
|
||||
spotify: no
|
||||
deezer: no
|
||||
beatport: no
|
||||
tidal: no
|
||||
|
||||
match:
|
||||
strong_rec_thresh: 0.04
|
||||
@@ -218,5 +147,3 @@ match:
|
||||
ignore_video_tracks: yes
|
||||
track_length_grace: 10
|
||||
track_length_max: 30
|
||||
album_disambig_fields: data_source media year country label catalognum albumdisambig
|
||||
singleton_disambig_fields: data_source index track_alt album
|
||||
|
||||
@@ -16,20 +16,12 @@
|
||||
Library.
|
||||
"""
|
||||
|
||||
from .db import Database, Model, Results
|
||||
from .query import (
|
||||
AndQuery,
|
||||
FieldQuery,
|
||||
InvalidQueryError,
|
||||
MatchQuery,
|
||||
OrQuery,
|
||||
Query,
|
||||
)
|
||||
from .queryparse import (
|
||||
parse_sorted_query,
|
||||
query_from_strings,
|
||||
sort_from_strings,
|
||||
)
|
||||
from .db import Model, Database
|
||||
from .query import Query, FieldQuery, MatchQuery, AndQuery, OrQuery
|
||||
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
|
||||
|
||||
+238
-432
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
Reference in New Issue
Block a user