mirror of
https://github.com/rembo10/headphones.git
synced 2026-09-10 00:32:52 +01:00
Compare commits
108
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f43e8258d | ||
|
|
d667cdb5c9 | ||
|
|
c459eb1074 | ||
|
|
b039492072 | ||
|
|
0ac76fbebc | ||
|
|
62757c0fcf | ||
|
|
63c992a4e5 | ||
|
|
63549b2471 | ||
|
|
3ff431da4b | ||
|
|
8ccc3cb146 | ||
|
|
6764783aa7 | ||
|
|
828fc0ccda | ||
|
|
68a495127e | ||
|
|
26a6095851 | ||
|
|
95bd0a57ff | ||
|
|
5efbd561d6 | ||
|
|
b690850019 | ||
|
|
a09e91ff8a | ||
|
|
94d62430a0 | ||
|
|
2d5102d29c | ||
|
|
046d4d82b4 | ||
|
|
a63098a919 | ||
|
|
38d8e13e4e | ||
|
|
b3edfa0d87 | ||
|
|
aeebc67365 | ||
|
|
d6f22a2326 | ||
|
|
5b1d9c0750 | ||
|
|
ba666f68b8 | ||
|
|
000509353a | ||
|
|
c264c75282 | ||
|
|
4c15b33c58 | ||
|
|
167b5817e7 | ||
|
|
46c7582fb1 | ||
|
|
7b9e32f525 | ||
|
|
37c4e19663 | ||
|
|
e5beb5291d | ||
|
|
ebe8a60ca5 | ||
|
|
040de81d3c | ||
|
|
1bf8c35154 | ||
|
|
dae4d80490 | ||
|
|
acf73368c9 | ||
|
|
abac9b5a15 | ||
|
|
ace2b4f26a | ||
|
|
ebba115443 | ||
|
|
b54218abbd | ||
|
|
3e354ff128 | ||
|
|
b8168ec8eb | ||
|
|
c0c636d545 | ||
|
|
33d1d17c97 | ||
|
|
8fd13621f5 | ||
|
|
56674adfea | ||
|
|
489c6cbe71 | ||
|
|
6afe31bffc | ||
|
|
742529a92d | ||
|
|
944d066903 | ||
|
|
87819a3c74 | ||
|
|
c9fbe29c90 | ||
|
|
d78cb7d14e | ||
|
|
e8c392824f | ||
|
|
9811df2779 | ||
|
|
1a4865ed38 | ||
|
|
a06fb40f50 | ||
|
|
ad6a4f570e | ||
|
|
3685d32a7d | ||
|
|
152f5daa8c | ||
|
|
39054a04df | ||
|
|
1c4b9c10f0 | ||
|
|
73ca787cf1 | ||
|
|
c7bc852868 | ||
|
|
391b0cc465 | ||
|
|
4aaeaa704f | ||
|
|
4d14b028ff | ||
|
|
a78f38c174 | ||
|
|
14f2a6d22c | ||
|
|
2e4299efa7 | ||
|
|
0610c2fa93 | ||
|
|
9add571886 | ||
|
|
fcf59a9b38 | ||
|
|
74f9e91afc | ||
|
|
83398cb102 | ||
|
|
61c2e1f821 | ||
|
|
3e3047aef2 | ||
|
|
fff44e4631 | ||
|
|
0964371de8 | ||
|
|
654f923a8d | ||
|
|
b91206c64a | ||
|
|
c9ba59ee9a | ||
|
|
b7e35d5ff0 | ||
|
|
9d82143abe | ||
|
|
eaf2db6c59 | ||
|
|
586b9ed3c8 | ||
|
|
d89f4171da | ||
|
|
9f7be5348b | ||
|
|
9c254ff222 | ||
|
|
ba969fd3b8 | ||
|
|
c851d5ed1a | ||
|
|
2223928958 | ||
|
|
164c3cacbc | ||
|
|
16d4ac8895 | ||
|
|
f4d60226b3 | ||
|
|
9ca87e23b2 | ||
|
|
d934c865c6 | ||
|
|
de74cd2502 | ||
|
|
f41db714a9 | ||
|
|
f03b82e5f6 | ||
|
|
e2db680b9e | ||
|
|
a3db89c11d | ||
|
|
138d01db4a |
@@ -1,5 +1,39 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.6.3
|
||||||
|
Released 26 May 2024
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Hotfix for searcher not returning results
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.6.2...v0.6.3).
|
||||||
|
|
||||||
|
## v0.6.2
|
||||||
|
Released 26 May 2024
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Added soulseek support
|
||||||
|
* Added bandcamp support
|
||||||
|
* Changes and dependency updates to work with Python >= 3.12
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.6.1...v0.6.2).
|
||||||
|
|
||||||
|
## v0.6.1
|
||||||
|
R eleased 26 November 2023
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Dependency updates to work with > Python 3.11
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.6.0...v0.6.1).
|
||||||
|
|
||||||
|
## v0.6.0
|
||||||
|
Released 13 November 2022
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
* Updated to python 3
|
||||||
|
|
||||||
|
The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.20...v0.6.0).
|
||||||
|
|
||||||
## v0.5.20
|
## v0.5.20
|
||||||
Released 15 October 2021
|
Released 15 October 2021
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -17,8 +17,8 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
if sys.version_info <= (3, 5):
|
if sys.version_info <= (3, 6):
|
||||||
sys.stdout.write("Headphones requires Python >= 3.5\n")
|
sys.stdout.write("Headphones requires Python >= 3.7\n")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Ensure lib added to path, before any other imports
|
# Ensure lib added to path, before any other imports
|
||||||
|
|||||||
@@ -310,6 +310,16 @@
|
|||||||
<input type="text" name="usenet_retention" value="${config['usenet_retention']}" size="5">
|
<input type="text" name="usenet_retention" value="${config['usenet_retention']}" size="5">
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<fieldset title="Method for downloading Bandcamp.com files.">
|
||||||
|
<legend>Bandcamp</legend>
|
||||||
|
<div class="row">
|
||||||
|
<label title="Path to folder where Headphones can store raw downloads from Bandcamp.com.">
|
||||||
|
Bandcamp Directory
|
||||||
|
</label>
|
||||||
|
<input type="text" name="bandcamp_dir" value="${config['bandcamp_dir']}" size="50">
|
||||||
|
<small>Full path where raw MP3s will be stored, e.g. /Users/name/Downloads/bandcamp</small>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<fieldset title="Method for downloading torrent files.">
|
<fieldset title="Method for downloading torrent files.">
|
||||||
@@ -317,7 +327,7 @@
|
|||||||
<input type="radio" name="torrent_downloader" id="torrent_downloader_blackhole" value="0" ${config['torrent_downloader_blackhole']}> Black Hole
|
<input type="radio" name="torrent_downloader" id="torrent_downloader_blackhole" value="0" ${config['torrent_downloader_blackhole']}> Black Hole
|
||||||
<input type="radio" name="torrent_downloader" id="torrent_downloader_transmission" value="1" ${config['torrent_downloader_transmission']}> Transmission
|
<input type="radio" name="torrent_downloader" id="torrent_downloader_transmission" value="1" ${config['torrent_downloader_transmission']}> Transmission
|
||||||
<input type="radio" name="torrent_downloader" id="torrent_downloader_utorrent" value="2" ${config['torrent_downloader_utorrent']}> uTorrent (Beta)
|
<input type="radio" name="torrent_downloader" id="torrent_downloader_utorrent" value="2" ${config['torrent_downloader_utorrent']}> uTorrent (Beta)
|
||||||
<input type="radio" name="torrent_downloader" id="torrent_downloader_deluge" value="3" ${config['torrent_downloader_deluge']}> Deluge (Beta)
|
<input type="radio" name="torrent_downloader" id="torrent_downloader_deluge" value="3" ${config['torrent_downloader_deluge']}> Deluge
|
||||||
<input type="radio" name="torrent_downloader" id="torrent_downloader_qbittorrent" value="4" ${config['torrent_downloader_qbittorrent']}> QBitTorrent
|
<input type="radio" name="torrent_downloader" id="torrent_downloader_qbittorrent" value="4" ${config['torrent_downloader_qbittorrent']}> QBitTorrent
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset id="torrent_blackhole_options">
|
<fieldset id="torrent_blackhole_options">
|
||||||
@@ -438,6 +448,11 @@
|
|||||||
<input type="text" name="deluge_label" value="${config['deluge_label']}" size="30">
|
<input type="text" name="deluge_label" value="${config['deluge_label']}" size="30">
|
||||||
<small>Labels shouldn't contain spaces (requires Label plugin)</small>
|
<small>Labels shouldn't contain spaces (requires Label plugin)</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Download Directory</label>
|
||||||
|
<input type="text" name="deluge_download_directory" value="${config['deluge_download_directory']}" size="30">
|
||||||
|
<small>Directory where Deluge should download to</small>
|
||||||
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>Move When Completed</label>
|
<label>Move When Completed</label>
|
||||||
<input type="text" name="deluge_done_directory" value="${config['deluge_done_directory']}" size="30">
|
<input type="text" name="deluge_done_directory" value="${config['deluge_done_directory']}" size="30">
|
||||||
@@ -467,7 +482,33 @@
|
|||||||
<label>Prefer</label>
|
<label>Prefer</label>
|
||||||
<input type="radio" name="prefer_torrents" id="prefer_torrents_0" value="0" ${config['prefer_torrents_0']}>NZBs
|
<input type="radio" name="prefer_torrents" id="prefer_torrents_0" value="0" ${config['prefer_torrents_0']}>NZBs
|
||||||
<input type="radio" name="prefer_torrents" id="prefer_torrents_1" value="1" ${config['prefer_torrents_1']}>Torrents
|
<input type="radio" name="prefer_torrents" id="prefer_torrents_1" value="1" ${config['prefer_torrents_1']}>Torrents
|
||||||
<input type="radio" name="prefer_torrents" id="prefer_torrents_2" value="2" ${config['prefer_torrents_2']}>No Preference
|
<input type="radio" name="prefer_torrents" id="prefer_torrents_2" value="2" ${config['prefer_torrents_2']}>Soulseek
|
||||||
|
<input type="radio" name="prefer_torrents" id="prefer_torrents_3" value="3" ${config['prefer_torrents_3']}>No Preference
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Soulseek</legend>
|
||||||
|
<div class="row">
|
||||||
|
<label>Soulseek API URL</label>
|
||||||
|
<input type="text" name="soulseek_api_url" value="${config['soulseek_api_url']}" size="50">
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label>Soulseek API KEY</label>
|
||||||
|
<input type="text" name="soulseek_api_key" value="${config['soulseek_api_key']}" size="20">
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label title="Path to folder where Headphones can find the downloads.">
|
||||||
|
Soulseek Download Dir:
|
||||||
|
</label>
|
||||||
|
<input type="text" name="soulseek_download_dir" value="${config['soulseek_download_dir']}" size="50">
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label title="Path to folder where Headphones can find the downloads.">
|
||||||
|
Soulseek Incomplete Download Dir:
|
||||||
|
</label>
|
||||||
|
<input type="text" name="soulseek_incomplete_download_dir" value="${config['soulseek_incomplete_download_dir']}" size="50">
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</td>
|
</td>
|
||||||
@@ -579,6 +620,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Other</legend>
|
||||||
|
<fieldset>
|
||||||
|
<div class="row checkbox left">
|
||||||
|
<input id="use_bandcamp" type="checkbox" class="bigcheck" name="use_bandcamp" value="1" ${config['use_bandcamp']} /><label for="use_bandcamp"><span class="option">Bandcamp</span></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<div class="row checkbox left">
|
||||||
|
<input id="use_soulseek" type="checkbox" class="bigcheck" name="use_soulseek" value="1" ${config['use_soulseek']} /><label for="use_soulseek"><span class="option">Soulseek</span></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</fieldset>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -601,26 +655,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<div class="row checkbox left">
|
|
||||||
<input id="use_waffles" type="checkbox" class="bigcheck" name="use_waffles" value="1" ${config['use_waffles']} /><label for="use_waffles"><span class="option">Waffles.ch</span></label>
|
|
||||||
</div>
|
|
||||||
<div class="config">
|
|
||||||
<div class="row">
|
|
||||||
<label>UID Number</label>
|
|
||||||
<input type="text" name="waffles_uid" value="${config['waffles_uid']}" size="36">
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label>Passkey</label>
|
|
||||||
<input type="password" name="waffles_passkey" value="${config['waffles_passkey']}" size="36">
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label>Seed Ratio</label>
|
|
||||||
<input type="text" class="override-float" name="waffles_ratio" value="${config['waffles_ratio']}" size="10" title="Stop seeding when ratio met, 0 = unlimited. Scheduled job will remove torrent when post processed and finished seeding">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div class="row checkbox left">
|
<div class="row checkbox left">
|
||||||
<input id="use_rutracker" type="checkbox" class="bigcheck" name="use_rutracker" value="1" ${config['use_rutracker']} /><label for="use_rutracker"><span class="option">rutracker.org</span></label>
|
<input id="use_rutracker" type="checkbox" class="bigcheck" name="use_rutracker" value="1" ${config['use_rutracker']} /><label for="use_rutracker"><span class="option">rutracker.org</span></label>
|
||||||
@@ -674,6 +708,10 @@
|
|||||||
<input id="use_redacted" type="checkbox" class="bigcheck" name="use_redacted" value="1" ${config['use_redacted']} /><label for="use_redacted"><span class="option">Redacted</span></label>
|
<input id="use_redacted" type="checkbox" class="bigcheck" name="use_redacted" value="1" ${config['use_redacted']} /><label for="use_redacted"><span class="option">Redacted</span></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="config">
|
<div class="config">
|
||||||
|
<div class="row">
|
||||||
|
<label>Api Key</label>
|
||||||
|
<input type="text" name="redacted_apikey" value="${config['redacted_apikey']}" size="36">
|
||||||
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>Username</label>
|
<label>Username</label>
|
||||||
<input type="text" name="redacted_username" value="${config['redacted_username']}" size="36">
|
<input type="text" name="redacted_username" value="${config['redacted_username']}" size="36">
|
||||||
@@ -695,7 +733,7 @@
|
|||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div class="row checkbox left">
|
<div class="row checkbox left">
|
||||||
<input id="use_torznab" type="checkbox" class="bigcheck" name="use_torznab" value="1" ${config['use_torznab']} /><label for="use_torznab"><span class="option">Jackett / Torznab Providers</span></label>
|
<input id="use_torznab" type="checkbox" class="bigcheck" name="use_torznab" value="1" ${config['use_torznab']} /><label for="use_torznab"><span class="option">Torznab Providers</span></label>
|
||||||
</div>
|
</div>
|
||||||
<div id="torznab_providers">
|
<div id="torznab_providers">
|
||||||
<div class="config" id="torznab1">
|
<div class="config" id="torznab1">
|
||||||
@@ -754,22 +792,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<div class="row checkbox left">
|
|
||||||
<input id="use_oldpiratebay" type="checkbox" class="bigcheck" name="use_oldpiratebay" value="1" ${config['use_oldpiratebay']} /><label for="use_oldpiratebay"><span class="option">Old Pirate Bay</span></label>
|
|
||||||
</div>
|
|
||||||
<div class="config">
|
|
||||||
<div class="row">
|
|
||||||
<label>URL</label>
|
|
||||||
<input type="text" name="oldpiratebay_url" value="${config['oldpiratebay_url']}" size="36">
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label>Seed Ratio</label>
|
|
||||||
<input type="text" class="override-float" name="oldpiratebay_ratio" value="${config['oldpiratebay_ratio']}" size="10" title="Stop seeding when ratio met, 0 = unlimited. Scheduled job will remove torrent when post processed and finished seeding.">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -970,20 +992,6 @@
|
|||||||
<table class="configtable" summary="Notifications">
|
<table class="configtable" summary="Notifications">
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<fieldset>
|
|
||||||
<div class="row checkbox left">
|
|
||||||
<input type="checkbox" class="bigcheck" name="boxcar_enabled" id="boxcar" value="1" ${config['boxcar_enabled']} /><label for="boxcar"><span class="option">Boxcar 2</span></label>
|
|
||||||
</div>
|
|
||||||
<div id="boxcar_options">
|
|
||||||
<div class="row">
|
|
||||||
<label>Access Token</label><input type="text" name="boxcar_token" value="${config['boxcar_token']}" size="35">
|
|
||||||
</div>
|
|
||||||
<div class="row checkbox">
|
|
||||||
<input type="checkbox" name="boxcar_onsnatch" value="1" ${config['boxcar_onsnatch']} /><label>Notify on snatch?</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div class="row checkbox left">
|
<div class="row checkbox left">
|
||||||
<input type="checkbox" class="bigcheck" name="email_enabled" id="email" value="1" ${config['email_enabled']} /><label for="email"><span class="option">Email</span></label>
|
<input type="checkbox" class="bigcheck" name="email_enabled" id="email" value="1" ${config['email_enabled']} /><label for="email"><span class="option">Email</span></label>
|
||||||
@@ -1019,22 +1027,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
|
||||||
<div class="row checkbox left">
|
|
||||||
<input type="checkbox" class="bigcheck" name="growl_enabled" id="growl" value="1" ${config['growl_enabled']} /><label for="growl"><span class="option">Growl</span></label>
|
|
||||||
</div>
|
|
||||||
<div id="growloptions">
|
|
||||||
<div class="row">
|
|
||||||
<label>Growl Host:Port</label><input type="text" name="growl_host" value="${config['growl_host']}" size="30">
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label>Growl Password</label><input type="password" name="growl_password" value="${config['growl_password'] | h}" size="30">
|
|
||||||
</div>
|
|
||||||
<div class="row checkbox">
|
|
||||||
<input type="checkbox" name="growl_onsnatch" value="1" ${config['growl_onsnatch']} /><label>Notify on snatch?</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div class="row checkbox left">
|
<div class="row checkbox left">
|
||||||
@@ -1123,18 +1115,9 @@
|
|||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div class="row checkbox left">
|
<div class="row checkbox left">
|
||||||
<input type="checkbox" class="bigcheck" name="osx_notify_enabled" id="osx_notify" value="1" ${config['osx_notify_enabled']} /><label for="osx_notify"><span class="option">OS X</span></label>
|
<input type="checkbox" class="bigcheck" name="osx_notify_enabled" id="osx_notify" value="1" ${config['osx_notify_enabled']} /><label for="osx_notify"><span class="option">macOS</span></label>
|
||||||
</div>
|
</div>
|
||||||
<div id="osx_notify_options">
|
<div id="osx_notify_options">
|
||||||
<div class="row">
|
|
||||||
<input type="text" id="osx_notify_reg" name="osx_notify_app" value="${config['osx_notify_app']}" size="50"><label>Register Notify App</label>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<small>Enter the path/application name to be registered with the Notification Center, default is /Applications/Headphones</small>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<input type="button" value="Register" id="osxnotifyregister"><label></label>
|
|
||||||
</div>
|
|
||||||
<div class="row checkbox">
|
<div class="row checkbox">
|
||||||
<input type="checkbox" name="osx_notify_onsnatch" value="1" ${config['osx_notify_onsnatch']} /><label>Notify on snatch?</label>
|
<input type="checkbox" name="osx_notify_onsnatch" value="1" ${config['osx_notify_onsnatch']} /><label>Notify on snatch?</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -1370,17 +1353,20 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<label>File Format</label>
|
<label>File Format</label>
|
||||||
<input type="text" name="file_format" value="${config['file_format']}" size="43">
|
<input type="text" name="file_format" value="${config['file_format']}" size="43">
|
||||||
<small>Use: $Disc/$disc (disc #), $Track/$track (track #), $Title/$title, $Artist/$artist, $Album/$album and $Year/$year. Put optional variables in curly braces, use single-quote marks to escape curly braces literally ('{', '}').</small>
|
<small>Use: In addition to the above, there is also $Title/$title (track title), $Track (track #), $Disc (disc #), $DiscTotal.</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="checkbox row clearfix">
|
<div class="checkbox row left clearfix nopad">
|
||||||
<input type="checkbox" name="file_underscores" id="file_underscores" value="1" ${config['file_underscores']}/><label>Use underscores instead of spaces</label>
|
<input type="checkbox" name="file_underscores" id="file_underscores" value="1" ${config['file_underscores']}/><label>Use underscores instead of spaces</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="checkbox row left clearfix nopad">
|
||||||
|
<input type="checkbox" name="rename_single_disc_ignore" id="rename_single_disc_ignore" value="1" ${config['rename_single_disc_ignore']}/><label>Don't include disc# for single disc albums</label>
|
||||||
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Re-Encoding Options</legend>
|
<legend>Re-Encoding Options</legend>
|
||||||
<small class="heading"><i class="fa fa-info-circle"></i> Note: this option requires the lame, ffmpeg or xld encoder</small>
|
<small class="heading"><i class="fa fa-info-circle"></i> Note: this option requires the lame, ffmpeg or xld encoder</small>
|
||||||
<div class="checkbox row clearfix">
|
<div class="checkbox row left clearfix nopad">
|
||||||
<input type="checkbox" name="music_encoder" id="music_encoder" value="1" ${config['music_encoder']}/><label>Re-encode downloads during postprocessing</label>
|
<input type="checkbox" name="music_encoder" id="music_encoder" value="1" ${config['music_encoder']}/><label>Re-encode downloads during postprocessing</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="encoderoptions" class="row clearfix checkbox">
|
<div id="encoderoptions" class="row clearfix checkbox">
|
||||||
@@ -1415,7 +1401,7 @@
|
|||||||
libavselect = ''
|
libavselect = ''
|
||||||
%>
|
%>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label title="Name of encoder to use. Lame, FFmpeg and libav are available for most Linux distributions. On Ubuntu, libav replaces FFmpeg. xld is OS X-only.">
|
<label title="Name of encoder to use. Lame, FFmpeg and libav are available for most Linux distributions. On Ubuntu, libav replaces FFmpeg. xld is macOS only.">
|
||||||
Encoder
|
Encoder
|
||||||
</label>
|
</label>
|
||||||
<select name="encoder" id="encoder">
|
<select name="encoder" id="encoder">
|
||||||
@@ -1651,6 +1637,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Last.fm</legend>
|
||||||
|
<div id="lastfmoptions">
|
||||||
|
<div class="row">
|
||||||
|
<label>API Key</label>
|
||||||
|
<input type="text" name="lastfm_apikey" value="${config['lastfm_apikey']}" size="40" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Songkick</legend>
|
<legend>Songkick</legend>
|
||||||
<div class="row checkbox">
|
<div class="row checkbox">
|
||||||
@@ -1965,25 +1961,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if ($("#growl").is(":checked"))
|
|
||||||
{
|
|
||||||
$("#growloptions").show();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
$("#growloptions").hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#growl").click(function(){
|
|
||||||
if ($("#growl").is(":checked"))
|
|
||||||
{
|
|
||||||
$("#growloptions").slideDown();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
$("#growloptions").slideUp();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($("#prowl").is(":checked"))
|
if ($("#prowl").is(":checked"))
|
||||||
{
|
{
|
||||||
@@ -2248,25 +2225,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if ($("#boxcar").is(":checked"))
|
|
||||||
{
|
|
||||||
$("#boxcar_options").show();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
$("#boxcar_options").hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#boxcar").click(function(){
|
|
||||||
if ($("#boxcar").is(":checked"))
|
|
||||||
{
|
|
||||||
$("#boxcar_options").slideDown();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
$("#boxcar_options").slideUp();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($("#subsonic").is(":checked"))
|
if ($("#subsonic").is(":checked"))
|
||||||
{
|
{
|
||||||
@@ -2551,8 +2509,6 @@
|
|||||||
initConfigCheckbox("#use_omgwtfnzbs");
|
initConfigCheckbox("#use_omgwtfnzbs");
|
||||||
initConfigCheckbox("#use_torznab");
|
initConfigCheckbox("#use_torznab");
|
||||||
initConfigCheckbox("#use_piratebay");
|
initConfigCheckbox("#use_piratebay");
|
||||||
initConfigCheckbox("#use_oldpiratebay");
|
|
||||||
initConfigCheckbox("#use_waffles");
|
|
||||||
initConfigCheckbox("#use_rutracker");
|
initConfigCheckbox("#use_rutracker");
|
||||||
initConfigCheckbox("#use_orpheus");
|
initConfigCheckbox("#use_orpheus");
|
||||||
initConfigCheckbox("#use_redacted");
|
initConfigCheckbox("#use_redacted");
|
||||||
@@ -2579,11 +2535,6 @@
|
|||||||
$('#ajaxMsg').addClass('success').fadeIn().delay(3000).fadeOut();
|
$('#ajaxMsg').addClass('success').fadeIn().delay(3000).fadeOut();
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#osxnotifyregister').click(function () {
|
|
||||||
var osx_notify_app = $("#osx_notify_reg").val();
|
|
||||||
$.get("/osxnotifyregister", {'app': osx_notify_app}, function (data) { $('#ajaxMsg').html("<div class='msg'><span class='ui-icon ui-icon-check'></span>"+data+"</div>"); });
|
|
||||||
$('#ajaxMsg').addClass('success').fadeIn().delay(3000).fadeOut()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
|
|||||||
@@ -56,6 +56,8 @@
|
|||||||
fileid = 'torrent'
|
fileid = 'torrent'
|
||||||
if item['URL'].find('codeshy') != -1:
|
if item['URL'].find('codeshy') != -1:
|
||||||
fileid = 'nzb'
|
fileid = 'nzb'
|
||||||
|
if item['URL'].find('bandcamp') != -1:
|
||||||
|
fileid = 'bandcamp'
|
||||||
|
|
||||||
folder = 'Folder: ' + item['FolderName']
|
folder = 'Folder: ' + item['FolderName']
|
||||||
|
|
||||||
|
|||||||
+31
-6
@@ -28,11 +28,34 @@ def getAlbumArt(albumid):
|
|||||||
|
|
||||||
# CAA
|
# CAA
|
||||||
logger.info("Searching for artwork at CAA")
|
logger.info("Searching for artwork at CAA")
|
||||||
artwork_path = 'https://coverartarchive.org/release-group/%s/front' % albumid
|
#artwork_path = 'https://coverartarchive.org/release-group/%s/front' % albumid
|
||||||
artwork = getartwork(artwork_path)
|
artwork_path = 'https://coverartarchive.org/release-group/%s' % albumid
|
||||||
if artwork:
|
|
||||||
logger.info("Artwork found at CAA")
|
data = request.request_json(artwork_path, timeout=20, whitelist_status_code=404)
|
||||||
return artwork_path, artwork
|
|
||||||
|
image_url = None
|
||||||
|
if data:
|
||||||
|
for item in data.get("images", []):
|
||||||
|
try:
|
||||||
|
if "Front" not in item["types"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Use desired size
|
||||||
|
image_url = item["image"]
|
||||||
|
if headphones.CONFIG.ALBUM_ART_MAX_WIDTH:
|
||||||
|
if isinstance(item.get("thumbnails"), dict):
|
||||||
|
image_url = item["thumbnails"].get(
|
||||||
|
headphones.CONFIG.ALBUM_ART_MAX_WIDTH, image_url
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if image_url:
|
||||||
|
artwork = getartwork(image_url)
|
||||||
|
if artwork:
|
||||||
|
logger.info("Artwork found at CAA")
|
||||||
|
return artwork_path, artwork
|
||||||
|
|
||||||
# Amazon
|
# Amazon
|
||||||
logger.info("Searching for artwork at Amazon")
|
logger.info("Searching for artwork at Amazon")
|
||||||
@@ -162,12 +185,14 @@ def getartwork(artwork_path):
|
|||||||
"url": artwork_path,
|
"url": artwork_path,
|
||||||
"w": maxwidth
|
"w": maxwidth
|
||||||
}
|
}
|
||||||
|
headers = {"User-Agent": "Headphones"}
|
||||||
r = request.request_response(
|
r = request.request_response(
|
||||||
url,
|
url,
|
||||||
params=params,
|
params=params,
|
||||||
timeout=20,
|
timeout=20,
|
||||||
stream=True,
|
stream=True,
|
||||||
whitelist_status_code=404
|
whitelist_status_code=404,
|
||||||
|
headers=headers
|
||||||
)
|
)
|
||||||
if r:
|
if r:
|
||||||
for chunk in r.iter_content(chunk_size=1024):
|
for chunk in r.iter_content(chunk_size=1024):
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# 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
|
||||||
+3
-1
@@ -545,11 +545,13 @@ class Cache(object):
|
|||||||
"url": thumb_url,
|
"url": thumb_url,
|
||||||
"w": 300
|
"w": 300
|
||||||
}
|
}
|
||||||
|
headers = {"User-Agent": "Headphones"}
|
||||||
artwork_thumb = request.request_content(
|
artwork_thumb = request.request_content(
|
||||||
url,
|
url,
|
||||||
params=params,
|
params=params,
|
||||||
timeout=20,
|
timeout=20,
|
||||||
whitelist_status_code=404
|
whitelist_status_code=404,
|
||||||
|
headers=headers
|
||||||
)
|
)
|
||||||
if artwork_thumb:
|
if artwork_thumb:
|
||||||
with open(thumb_path, 'wb') as f:
|
with open(thumb_path, 'wb') as f:
|
||||||
|
|||||||
@@ -102,36 +102,6 @@ class Quality:
|
|||||||
|
|
||||||
return (anyQualities, bestQualities)
|
return (anyQualities, bestQualities)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def nameQuality(name):
|
|
||||||
|
|
||||||
def checkName(list, func):
|
|
||||||
return func([re.search(x, name, re.I) for x in list])
|
|
||||||
|
|
||||||
name = os.path.basename(name)
|
|
||||||
|
|
||||||
# if we have our exact text then assume we put it there
|
|
||||||
for x in Quality.qualityStrings:
|
|
||||||
if x == Quality.UNKNOWN:
|
|
||||||
continue
|
|
||||||
|
|
||||||
regex = '\W' + Quality.qualityStrings[x].replace(' ', '\W') + '\W'
|
|
||||||
regex_match = re.search(regex, name, re.I)
|
|
||||||
if regex_match:
|
|
||||||
return x
|
|
||||||
|
|
||||||
# TODO: fix quality checking here
|
|
||||||
if checkName(["mp3", "192"], any) and not checkName(["flac"], all):
|
|
||||||
return Quality.B192
|
|
||||||
elif checkName(["mp3", "256"], any) and not checkName(["flac"], all):
|
|
||||||
return Quality.B256
|
|
||||||
elif checkName(["mp3", "vbr"], any) and not checkName(["flac"], all):
|
|
||||||
return Quality.VBR
|
|
||||||
elif checkName(["mp3", "320"], any) and not checkName(["flac"], all):
|
|
||||||
return Quality.B320
|
|
||||||
else:
|
|
||||||
return Quality.UNKNOWN
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def assumeQuality(name):
|
def assumeQuality(name):
|
||||||
if name.lower().endswith(".mp3"):
|
if name.lower().endswith(".mp3"):
|
||||||
@@ -158,13 +128,6 @@ class Quality:
|
|||||||
|
|
||||||
return (Quality.NONE, status)
|
return (Quality.NONE, status)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def statusFromName(name, assume=True):
|
|
||||||
quality = Quality.nameQuality(name)
|
|
||||||
if assume and quality == Quality.UNKNOWN:
|
|
||||||
quality = Quality.assumeQuality(name)
|
|
||||||
return Quality.compositeStatus(DOWNLOADED, quality)
|
|
||||||
|
|
||||||
DOWNLOADED = None
|
DOWNLOADED = None
|
||||||
SNATCHED = None
|
SNATCHED = None
|
||||||
SNATCHED_PROPER = None
|
SNATCHED_PROPER = None
|
||||||
|
|||||||
+19
-23
@@ -54,9 +54,6 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'BITRATE': (int, 'General', 192),
|
'BITRATE': (int, 'General', 192),
|
||||||
'BLACKHOLE': (int, 'General', 0),
|
'BLACKHOLE': (int, 'General', 0),
|
||||||
'BLACKHOLE_DIR': (path, 'General', ''),
|
'BLACKHOLE_DIR': (path, 'General', ''),
|
||||||
'BOXCAR_ENABLED': (int, 'Boxcar', 0),
|
|
||||||
'BOXCAR_ONSNATCH': (int, 'Boxcar', 0),
|
|
||||||
'BOXCAR_TOKEN': (str, 'Boxcar', ''),
|
|
||||||
'CACHE_DIR': (path, 'General', ''),
|
'CACHE_DIR': (path, 'General', ''),
|
||||||
'CACHE_SIZEMB': (int, 'Advanced', 32),
|
'CACHE_SIZEMB': (int, 'Advanced', 32),
|
||||||
'CHECK_GITHUB': (int, 'General', 1),
|
'CHECK_GITHUB': (int, 'General', 1),
|
||||||
@@ -80,6 +77,7 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'DELUGE_PASSWORD': (str, 'Deluge', ''),
|
'DELUGE_PASSWORD': (str, 'Deluge', ''),
|
||||||
'DELUGE_LABEL': (str, 'Deluge', ''),
|
'DELUGE_LABEL': (str, 'Deluge', ''),
|
||||||
'DELUGE_DONE_DIRECTORY': (str, 'Deluge', ''),
|
'DELUGE_DONE_DIRECTORY': (str, 'Deluge', ''),
|
||||||
|
'DELUGE_DOWNLOAD_DIRECTORY': (str, 'Deluge', ''),
|
||||||
'DELUGE_PAUSED': (int, 'Deluge', 0),
|
'DELUGE_PAUSED': (int, 'Deluge', 0),
|
||||||
'DESTINATION_DIR': (str, 'General', ''),
|
'DESTINATION_DIR': (str, 'General', ''),
|
||||||
'DETECT_BITRATE': (int, 'General', 0),
|
'DETECT_BITRATE': (int, 'General', 0),
|
||||||
@@ -124,10 +122,6 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'GIT_BRANCH': (str, 'General', 'master'),
|
'GIT_BRANCH': (str, 'General', 'master'),
|
||||||
'GIT_PATH': (path, 'General', ''),
|
'GIT_PATH': (path, 'General', ''),
|
||||||
'GIT_USER': (str, 'General', 'rembo10'),
|
'GIT_USER': (str, 'General', 'rembo10'),
|
||||||
'GROWL_ENABLED': (int, 'Growl', 0),
|
|
||||||
'GROWL_HOST': (str, 'Growl', ''),
|
|
||||||
'GROWL_ONSNATCH': (int, 'Growl', 0),
|
|
||||||
'GROWL_PASSWORD': (str, 'Growl', ''),
|
|
||||||
'HEADPHONES_INDEXER': (bool_int, 'General', False),
|
'HEADPHONES_INDEXER': (bool_int, 'General', False),
|
||||||
'HPPASS': (str, 'General', ''),
|
'HPPASS': (str, 'General', ''),
|
||||||
'HPUSER': (str, 'General', ''),
|
'HPUSER': (str, 'General', ''),
|
||||||
@@ -155,9 +149,10 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'KEEP_TORRENT_FILES': (int, 'General', 0),
|
'KEEP_TORRENT_FILES': (int, 'General', 0),
|
||||||
'KEEP_TORRENT_FILES_DIR': (path, 'General', ''),
|
'KEEP_TORRENT_FILES_DIR': (path, 'General', ''),
|
||||||
'LASTFM_USERNAME': (str, 'General', ''),
|
'LASTFM_USERNAME': (str, 'General', ''),
|
||||||
|
'LASTFM_APIKEY': (str, 'General', ''),
|
||||||
'LAUNCH_BROWSER': (int, 'General', 1),
|
'LAUNCH_BROWSER': (int, 'General', 1),
|
||||||
'LIBRARYSCAN': (int, 'General', 1),
|
'LIBRARYSCAN': (int, 'General', 1),
|
||||||
'LIBRARYSCAN_INTERVAL': (int, 'General', 300),
|
'LIBRARYSCAN_INTERVAL': (int, 'General', 24),
|
||||||
'LMS_ENABLED': (int, 'LMS', 0),
|
'LMS_ENABLED': (int, 'LMS', 0),
|
||||||
'LMS_HOST': (str, 'LMS', ''),
|
'LMS_HOST': (str, 'LMS', ''),
|
||||||
'LOG_DIR': (path, 'General', ''),
|
'LOG_DIR': (path, 'General', ''),
|
||||||
@@ -195,15 +190,11 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'OMGWTFNZBS_UID': (str, 'omgwtfnzbs', ''),
|
'OMGWTFNZBS_UID': (str, 'omgwtfnzbs', ''),
|
||||||
'OPEN_MAGNET_LINKS': (int, 'General', 0), # 0: Ignore, 1: Open, 2: Convert, 3: Embed (rtorrent)
|
'OPEN_MAGNET_LINKS': (int, 'General', 0), # 0: Ignore, 1: Open, 2: Convert, 3: Embed (rtorrent)
|
||||||
'MAGNET_LINKS': (int, 'General', 0),
|
'MAGNET_LINKS': (int, 'General', 0),
|
||||||
'OSX_NOTIFY_APP': (str, 'OSX_Notify', '/Applications/Headphones'),
|
|
||||||
'OSX_NOTIFY_ENABLED': (int, 'OSX_Notify', 0),
|
'OSX_NOTIFY_ENABLED': (int, 'OSX_Notify', 0),
|
||||||
'OSX_NOTIFY_ONSNATCH': (int, 'OSX_Notify', 0),
|
'OSX_NOTIFY_ONSNATCH': (int, 'OSX_Notify', 0),
|
||||||
'PIRATEBAY': (int, 'Piratebay', 0),
|
'PIRATEBAY': (int, 'Piratebay', 0),
|
||||||
'PIRATEBAY_PROXY_URL': (str, 'Piratebay', ''),
|
'PIRATEBAY_PROXY_URL': (str, 'Piratebay', ''),
|
||||||
'PIRATEBAY_RATIO': (str, 'Piratebay', ''),
|
'PIRATEBAY_RATIO': (str, 'Piratebay', ''),
|
||||||
'OLDPIRATEBAY': (int, 'Old Piratebay', 0),
|
|
||||||
'OLDPIRATEBAY_URL': (str, 'Old Piratebay', ''),
|
|
||||||
'OLDPIRATEBAY_RATIO': (str, 'Old Piratebay', ''),
|
|
||||||
'PLEX_CLIENT_HOST': (str, 'Plex', ''),
|
'PLEX_CLIENT_HOST': (str, 'Plex', ''),
|
||||||
'PLEX_ENABLED': (int, 'Plex', 0),
|
'PLEX_ENABLED': (int, 'Plex', 0),
|
||||||
'PLEX_NOTIFY': (int, 'Plex', 0),
|
'PLEX_NOTIFY': (int, 'Plex', 0),
|
||||||
@@ -240,6 +231,7 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'QBITTORRENT_PASSWORD': (str, 'QBitTorrent', ''),
|
'QBITTORRENT_PASSWORD': (str, 'QBitTorrent', ''),
|
||||||
'QBITTORRENT_USERNAME': (str, 'QBitTorrent', ''),
|
'QBITTORRENT_USERNAME': (str, 'QBitTorrent', ''),
|
||||||
'RENAME_FILES': (int, 'General', 0),
|
'RENAME_FILES': (int, 'General', 0),
|
||||||
|
'RENAME_SINGLE_DISC_IGNORE': (int, 'General', 0),
|
||||||
'RENAME_UNPROCESSED': (bool_int, 'General', 1),
|
'RENAME_UNPROCESSED': (bool_int, 'General', 1),
|
||||||
'RENAME_FROZEN': (bool_int, 'General', 1),
|
'RENAME_FROZEN': (bool_int, 'General', 1),
|
||||||
'REPLACE_EXISTING_FOLDERS': (int, 'General', 0),
|
'REPLACE_EXISTING_FOLDERS': (int, 'General', 0),
|
||||||
@@ -267,6 +259,11 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'SONGKICK_ENABLED': (int, 'Songkick', 1),
|
'SONGKICK_ENABLED': (int, 'Songkick', 1),
|
||||||
'SONGKICK_FILTER_ENABLED': (int, 'Songkick', 0),
|
'SONGKICK_FILTER_ENABLED': (int, 'Songkick', 0),
|
||||||
'SONGKICK_LOCATION': (str, 'Songkick', ''),
|
'SONGKICK_LOCATION': (str, 'Songkick', ''),
|
||||||
|
'SOULSEEK_API_URL': (str, 'Soulseek', ''),
|
||||||
|
'SOULSEEK_API_KEY': (str, 'Soulseek', ''),
|
||||||
|
'SOULSEEK_DOWNLOAD_DIR': (str, 'Soulseek', ''),
|
||||||
|
'SOULSEEK_INCOMPLETE_DOWNLOAD_DIR': (str, 'Soulseek', ''),
|
||||||
|
'SOULSEEK': (int, 'Soulseek', 0),
|
||||||
'SUBSONIC_ENABLED': (int, 'Subsonic', 0),
|
'SUBSONIC_ENABLED': (int, 'Subsonic', 0),
|
||||||
'SUBSONIC_HOST': (str, 'Subsonic', ''),
|
'SUBSONIC_HOST': (str, 'Subsonic', ''),
|
||||||
'SUBSONIC_PASSWORD': (str, 'Subsonic', ''),
|
'SUBSONIC_PASSWORD': (str, 'Subsonic', ''),
|
||||||
@@ -300,11 +297,8 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'UTORRENT_USERNAME': (str, 'uTorrent', ''),
|
'UTORRENT_USERNAME': (str, 'uTorrent', ''),
|
||||||
'VERIFY_SSL_CERT': (bool_int, 'Advanced', 1),
|
'VERIFY_SSL_CERT': (bool_int, 'Advanced', 1),
|
||||||
'WAIT_UNTIL_RELEASE_DATE': (int, 'General', 0),
|
'WAIT_UNTIL_RELEASE_DATE': (int, 'General', 0),
|
||||||
'WAFFLES': (int, 'Waffles', 0),
|
|
||||||
'WAFFLES_PASSKEY': (str, 'Waffles', ''),
|
|
||||||
'WAFFLES_RATIO': (str, 'Waffles', ''),
|
|
||||||
'WAFFLES_UID': (str, 'Waffles', ''),
|
|
||||||
'REDACTED': (int, 'Redacted', 0),
|
'REDACTED': (int, 'Redacted', 0),
|
||||||
|
'REDACTED_APIKEY': (str, 'Redacted', ''),
|
||||||
'REDACTED_USERNAME': (str, 'Redacted', ''),
|
'REDACTED_USERNAME': (str, 'Redacted', ''),
|
||||||
'REDACTED_PASSWORD': (str, 'Redacted', ''),
|
'REDACTED_PASSWORD': (str, 'Redacted', ''),
|
||||||
'REDACTED_RATIO': (str, 'Redacted', ''),
|
'REDACTED_RATIO': (str, 'Redacted', ''),
|
||||||
@@ -315,7 +309,9 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'XBMC_PASSWORD': (str, 'XBMC', ''),
|
'XBMC_PASSWORD': (str, 'XBMC', ''),
|
||||||
'XBMC_UPDATE': (int, 'XBMC', 0),
|
'XBMC_UPDATE': (int, 'XBMC', 0),
|
||||||
'XBMC_USERNAME': (str, 'XBMC', ''),
|
'XBMC_USERNAME': (str, 'XBMC', ''),
|
||||||
'XLDPROFILE': (str, 'General', '')
|
'XLDPROFILE': (str, 'General', ''),
|
||||||
|
'BANDCAMP': (int, 'General', 0),
|
||||||
|
'BANDCAMP_DIR': (path, 'General', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -327,7 +323,7 @@ class Config(object):
|
|||||||
def __init__(self, config_file):
|
def __init__(self, config_file):
|
||||||
""" Initialize the config with values from a file """
|
""" Initialize the config with values from a file """
|
||||||
self._config_file = config_file
|
self._config_file = config_file
|
||||||
self._config = ConfigParser()
|
self._config = ConfigParser(interpolation=None)
|
||||||
self._config.read(self._config_file)
|
self._config.read(self._config_file)
|
||||||
for key in list(_CONFIG_DEFINITIONS.keys()):
|
for key in list(_CONFIG_DEFINITIONS.keys()):
|
||||||
self.check_setting(key)
|
self.check_setting(key)
|
||||||
@@ -363,12 +359,12 @@ class Config(object):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
my_val = definition_type(self._config[section][ini_key])
|
my_val = definition_type(self._config[section][ini_key])
|
||||||
# ConfigParser interprets empty strings in the config
|
# ConfigParser interprets quotes in the config
|
||||||
# literally, so we need to sanitize it. It's not really
|
# literally, so we need to sanitize it. It's not really
|
||||||
# a config upgrade, since a user can at any time put
|
# a config upgrade, since a user can at any time put
|
||||||
# some_key = ''
|
# some_key = 'some_val'
|
||||||
if my_val == '""' or my_val == "''":
|
if type(my_val) in [str, path]:
|
||||||
my_val = ''
|
my_val = my_val.strip('"').strip("'")
|
||||||
except Exception:
|
except Exception:
|
||||||
my_val = default
|
my_val = default
|
||||||
self._config[section][ini_key] = str(my_val)
|
self._config[section][ini_key] = str(my_val)
|
||||||
@@ -376,7 +372,7 @@ class Config(object):
|
|||||||
|
|
||||||
def write(self):
|
def write(self):
|
||||||
""" Make a copy of the stored config and write it to the configured file """
|
""" Make a copy of the stored config and write it to the configured file """
|
||||||
new_config = ConfigParser()
|
new_config = ConfigParser(interpolation=None)
|
||||||
|
|
||||||
# first copy over everything from the old config, even if it is not
|
# first copy over everything from the old config, even if it is not
|
||||||
# correctly defined to keep from losing data
|
# correctly defined to keep from losing data
|
||||||
|
|||||||
+1
-1
@@ -117,7 +117,7 @@ class DBConnection:
|
|||||||
break
|
break
|
||||||
|
|
||||||
except sqlite3.OperationalError as e:
|
except sqlite3.OperationalError as e:
|
||||||
if "unable to open database file" in e.message or "database is locked" in e.message:
|
if "unable to open database file" in str(e) or "database is locked" in str(e):
|
||||||
dberror = e
|
dberror = e
|
||||||
if args is None:
|
if args is None:
|
||||||
logger.debug('Database error: %s. Query: %s', e, query)
|
logger.debug('Database error: %s. Query: %s', e, query)
|
||||||
|
|||||||
+26
-71
@@ -58,12 +58,12 @@ def _scrubber(text):
|
|||||||
if scrub_logs:
|
if scrub_logs:
|
||||||
try:
|
try:
|
||||||
# URL parameter values
|
# URL parameter values
|
||||||
text = re.sub('=[0-9a-zA-Z]*', '=REMOVED', text)
|
text = re.sub(r'=[0-9a-zA-Z]*', r'=REMOVED', text)
|
||||||
# Local host with port
|
# Local host with port
|
||||||
# text = re.sub('\:\/\/.*\:', '://REMOVED:', text) # just host
|
# text = re.sub('\:\/\/.*\:', '://REMOVED:', text) # just host
|
||||||
text = re.sub('\:\/\/.*\:[0-9]*', '://REMOVED:', text)
|
text = re.sub(r'\:\/\/.*\:[0-9]*', r'://REMOVED:', text)
|
||||||
# Session cookie
|
# Session cookie
|
||||||
text = re.sub("_session_id'\: '.*'", "_session_id': 'REMOVED'", text)
|
text = re.sub(r"_session_id'\: '.*'", r"_session_id': 'REMOVED'", text)
|
||||||
# Local Windows user path
|
# Local Windows user path
|
||||||
if text.lower().startswith('c:\\users\\'):
|
if text.lower().startswith('c:\\users\\'):
|
||||||
k = text.split('\\')
|
k = text.split('\\')
|
||||||
@@ -84,7 +84,6 @@ def addTorrent(link, data=None, name=None):
|
|||||||
result = {}
|
result = {}
|
||||||
retid = False
|
retid = False
|
||||||
url_orpheus = ['https://orpheus.network/', 'http://orpheus.network/']
|
url_orpheus = ['https://orpheus.network/', 'http://orpheus.network/']
|
||||||
url_waffles = ['https://waffles.ch/', 'http://waffles.ch/']
|
|
||||||
|
|
||||||
if link.lower().startswith('magnet:'):
|
if link.lower().startswith('magnet:'):
|
||||||
logger.debug('Deluge: Got a magnet link: %s' % _scrubber(link))
|
logger.debug('Deluge: Got a magnet link: %s' % _scrubber(link))
|
||||||
@@ -94,9 +93,6 @@ def addTorrent(link, data=None, name=None):
|
|||||||
|
|
||||||
elif link.lower().startswith('http://') or link.lower().startswith('https://'):
|
elif link.lower().startswith('http://') or link.lower().startswith('https://'):
|
||||||
logger.debug('Deluge: Got a URL: %s' % _scrubber(link))
|
logger.debug('Deluge: Got a URL: %s' % _scrubber(link))
|
||||||
if link.lower().startswith(tuple(url_waffles)):
|
|
||||||
if 'rss=' not in link:
|
|
||||||
link = link + '&rss=1'
|
|
||||||
if link.lower().startswith(tuple(url_orpheus)):
|
if link.lower().startswith(tuple(url_orpheus)):
|
||||||
logger.debug('Deluge: Using different User-Agent for this site')
|
logger.debug('Deluge: Using different User-Agent for this site')
|
||||||
user_agent = 'Headphones'
|
user_agent = 'Headphones'
|
||||||
@@ -128,9 +124,9 @@ def addTorrent(link, data=None, name=None):
|
|||||||
# Extract torrent name from .torrent
|
# Extract torrent name from .torrent
|
||||||
try:
|
try:
|
||||||
logger.debug('Deluge: Getting torrent name length')
|
logger.debug('Deluge: Getting torrent name length')
|
||||||
name_length = int(re.findall('name([0-9]*)\:.*?\:', str(torrentfile))[0])
|
name_length = int(re.findall(r'name([0-9]*)\:.*?\:', str(torrentfile))[0])
|
||||||
logger.debug('Deluge: Getting torrent name')
|
logger.debug('Deluge: Getting torrent name')
|
||||||
name = re.findall('name[0-9]*\:(.*?)\:', str(torrentfile))[0][:name_length]
|
name = re.findall(r'name[0-9]*\:(.*?)\:', str(torrentfile))[0][:name_length]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug('Deluge: Could not get torrent name, getting file name')
|
logger.debug('Deluge: Could not get torrent name, getting file name')
|
||||||
# get last part of link/path (name only)
|
# get last part of link/path (name only)
|
||||||
@@ -160,9 +156,9 @@ def addTorrent(link, data=None, name=None):
|
|||||||
# Extract torrent name from .torrent
|
# Extract torrent name from .torrent
|
||||||
try:
|
try:
|
||||||
logger.debug('Deluge: Getting torrent name length')
|
logger.debug('Deluge: Getting torrent name length')
|
||||||
name_length = int(re.findall('name([0-9]*)\:.*?\:', str(torrentfile))[0])
|
name_length = int(re.findall(r'name([0-9]*)\:.*?\:', str(torrentfile))[0])
|
||||||
logger.debug('Deluge: Getting torrent name')
|
logger.debug('Deluge: Getting torrent name')
|
||||||
name = re.findall('name[0-9]*\:(.*?)\:', str(torrentfile))[0][:name_length]
|
name = re.findall(r'name[0-9]*\:(.*?)\:', str(torrentfile))[0][:name_length]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug('Deluge: Could not get torrent name, getting file name')
|
logger.debug('Deluge: Could not get torrent name, getting file name')
|
||||||
# get last part of link/path (name only)
|
# get last part of link/path (name only)
|
||||||
@@ -466,16 +462,33 @@ def _add_torrent_url(result):
|
|||||||
|
|
||||||
def _add_torrent_file(result):
|
def _add_torrent_file(result):
|
||||||
logger.debug('Deluge: Adding file')
|
logger.debug('Deluge: Adding file')
|
||||||
|
|
||||||
|
options = {}
|
||||||
|
|
||||||
|
if headphones.CONFIG.DELUGE_DOWNLOAD_DIRECTORY:
|
||||||
|
options['download_location'] = headphones.CONFIG.DELUGE_DOWNLOAD_DIRECTORY
|
||||||
|
|
||||||
|
if headphones.CONFIG.DELUGE_DONE_DIRECTORY or headphones.CONFIG.DOWNLOAD_TORRENT_DIR:
|
||||||
|
options['move_completed'] = 1
|
||||||
|
if headphones.CONFIG.DELUGE_DONE_DIRECTORY:
|
||||||
|
options['move_completed_path'] = headphones.CONFIG.DELUGE_DONE_DIRECTORY
|
||||||
|
else:
|
||||||
|
options['move_completed_path'] = headphones.CONFIG.DOWNLOAD_TORRENT_DIR
|
||||||
|
|
||||||
|
if headphones.CONFIG.DELUGE_PAUSED:
|
||||||
|
options['add_paused'] = headphones.CONFIG.DELUGE_PAUSED
|
||||||
|
|
||||||
if not any(delugeweb_auth):
|
if not any(delugeweb_auth):
|
||||||
_get_auth()
|
_get_auth()
|
||||||
try:
|
try:
|
||||||
# content is torrent file contents that needs to be encoded to base64
|
# content is torrent file contents that needs to be encoded to base64
|
||||||
post_data = json.dumps({"method": "core.add_torrent_file",
|
post_data = json.dumps({"method": "core.add_torrent_file",
|
||||||
"params": [result['name'] + '.torrent',
|
"params": [result['name'] + '.torrent',
|
||||||
b64encode(result['content']).decode(), {}],
|
b64encode(result['content']).decode(),
|
||||||
|
options],
|
||||||
"id": 2})
|
"id": 2})
|
||||||
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
|
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
|
||||||
verify=deluge_verify_cert, headers=headers)
|
verify=deluge_verify_cert, headers=headers)
|
||||||
result['hash'] = json.loads(response.text)['result']
|
result['hash'] = json.loads(response.text)['result']
|
||||||
logger.debug('Deluge: Response was %s' % str(json.loads(response.text)))
|
logger.debug('Deluge: Response was %s' % str(json.loads(response.text)))
|
||||||
return json.loads(response.text)['result']
|
return json.loads(response.text)['result']
|
||||||
@@ -566,61 +579,3 @@ def setSeedRatio(result):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def setTorrentPath(result):
|
|
||||||
logger.debug('Deluge: Setting download path')
|
|
||||||
if not any(delugeweb_auth):
|
|
||||||
_get_auth()
|
|
||||||
|
|
||||||
try:
|
|
||||||
if headphones.CONFIG.DELUGE_DONE_DIRECTORY or headphones.CONFIG.DOWNLOAD_TORRENT_DIR:
|
|
||||||
post_data = json.dumps({"method": "core.set_torrent_move_completed",
|
|
||||||
"params": [result['hash'], True],
|
|
||||||
"id": 7})
|
|
||||||
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
|
|
||||||
verify=deluge_verify_cert, headers=headers)
|
|
||||||
|
|
||||||
if headphones.CONFIG.DELUGE_DONE_DIRECTORY:
|
|
||||||
move_to = headphones.CONFIG.DELUGE_DONE_DIRECTORY
|
|
||||||
else:
|
|
||||||
move_to = headphones.CONFIG.DOWNLOAD_TORRENT_DIR
|
|
||||||
|
|
||||||
if not os.path.exists(move_to):
|
|
||||||
logger.debug('Deluge: %s directory doesn\'t exist, let\'s create it' % move_to)
|
|
||||||
os.makedirs(move_to)
|
|
||||||
post_data = json.dumps({"method": "core.set_torrent_move_completed_path",
|
|
||||||
"params": [result['hash'], move_to],
|
|
||||||
"id": 8})
|
|
||||||
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
|
|
||||||
verify=deluge_verify_cert, headers=headers)
|
|
||||||
|
|
||||||
return not json.loads(response.text)['error']
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Deluge: Setting torrent move-to directory failed: %s' % str(e))
|
|
||||||
formatted_lines = traceback.format_exc().splitlines()
|
|
||||||
logger.error('; '.join(formatted_lines))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def setTorrentPause(result):
|
|
||||||
logger.debug('Deluge: Pausing torrent')
|
|
||||||
if not any(delugeweb_auth):
|
|
||||||
_get_auth()
|
|
||||||
|
|
||||||
try:
|
|
||||||
if headphones.CONFIG.DELUGE_PAUSED:
|
|
||||||
post_data = json.dumps({"method": "core.pause_torrent",
|
|
||||||
"params": [[result['hash']]],
|
|
||||||
"id": 9})
|
|
||||||
response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth,
|
|
||||||
verify=deluge_verify_cert, headers=headers)
|
|
||||||
|
|
||||||
return not json.loads(response.text)['error']
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Deluge: Setting torrent paused failed: %s' % str(e))
|
|
||||||
formatted_lines = traceback.format_exc().splitlines()
|
|
||||||
logger.error('; '.join(formatted_lines))
|
|
||||||
return None
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os.path
|
import os.path
|
||||||
|
|
||||||
import biplist
|
import plistlib
|
||||||
from headphones import logger
|
from headphones import logger
|
||||||
|
|
||||||
|
|
||||||
@@ -14,8 +14,9 @@ def getXldProfile(xldProfile):
|
|||||||
|
|
||||||
# Get xld preferences plist
|
# Get xld preferences plist
|
||||||
try:
|
try:
|
||||||
preferences = biplist.readPlist(expanded)
|
with open(expanded, 'rb') as _f:
|
||||||
except (biplist.InvalidPlistException, biplist.NotBinaryPlistException) as e:
|
preferences = plistlib.load(_f)
|
||||||
|
except Exception as e:
|
||||||
logger.error("Error reading xld preferences plist: %s", e)
|
logger.error("Error reading xld preferences plist: %s", e)
|
||||||
return (xldProfileNotFound, None, None)
|
return (xldProfileNotFound, None, None)
|
||||||
|
|
||||||
|
|||||||
+54
-34
@@ -14,25 +14,25 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from operator import itemgetter
|
import os
|
||||||
import unicodedata
|
import re
|
||||||
import datetime
|
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import glob
|
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
|
||||||
|
|
||||||
from beets import logging as beetslogging
|
from beets import logging as beetslogging
|
||||||
import six
|
|
||||||
from contextlib import contextmanager
|
|
||||||
|
|
||||||
import fnmatch
|
|
||||||
import functools
|
|
||||||
import re
|
|
||||||
import os
|
|
||||||
from mediafile import MediaFile, FileTypeError, UnreadableFileError
|
from mediafile import MediaFile, FileTypeError, UnreadableFileError
|
||||||
|
from six import text_type
|
||||||
from unidecode import unidecode
|
from unidecode import unidecode
|
||||||
|
|
||||||
import headphones
|
import headphones
|
||||||
|
|
||||||
|
|
||||||
@@ -52,7 +52,14 @@ def cmp(x, y):
|
|||||||
|
|
||||||
https://portingguide.readthedocs.io/en/latest/comparisons.html#the-cmp-function
|
https://portingguide.readthedocs.io/en/latest/comparisons.html#the-cmp-function
|
||||||
"""
|
"""
|
||||||
return (x > y) - (x < y)
|
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)
|
||||||
|
|
||||||
def multikeysort(items, columns):
|
def multikeysort(items, columns):
|
||||||
comparers = [
|
comparers = [
|
||||||
@@ -67,7 +74,7 @@ def multikeysort(items, columns):
|
|||||||
else:
|
else:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
return sorted(items, key=functools.cmp_to_key(comparer))
|
return sorted(items, key=cmp_to_key(comparer))
|
||||||
|
|
||||||
|
|
||||||
def checked(variable):
|
def checked(variable):
|
||||||
@@ -149,28 +156,25 @@ def convert_seconds(s):
|
|||||||
|
|
||||||
|
|
||||||
def today():
|
def today():
|
||||||
today = datetime.date.today()
|
return date.isoformat(date.today())
|
||||||
yyyymmdd = datetime.date.isoformat(today)
|
|
||||||
return yyyymmdd
|
|
||||||
|
|
||||||
|
|
||||||
def now():
|
def now():
|
||||||
now = datetime.datetime.now()
|
now = datetime.now()
|
||||||
return now.strftime("%Y-%m-%d %H:%M:%S")
|
return now.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
def get_age(date):
|
def is_valid_date(d):
|
||||||
try:
|
if not d:
|
||||||
split_date = date.split('-')
|
|
||||||
except:
|
|
||||||
return False
|
return False
|
||||||
|
else:
|
||||||
|
return bool(re.match(r'\d{4}-\d{2}-\d{2}', d))
|
||||||
|
|
||||||
try:
|
|
||||||
days_old = int(split_date[0]) * 365 + int(split_date[1]) * 30 + int(split_date[2])
|
|
||||||
except (IndexError, ValueError):
|
|
||||||
days_old = False
|
|
||||||
|
|
||||||
return days_old
|
def age(d):
|
||||||
|
'''Requires a valid date'''
|
||||||
|
delta = date.today() - date.fromisoformat(d)
|
||||||
|
return delta.days
|
||||||
|
|
||||||
|
|
||||||
def bytes_to_mb(bytes):
|
def bytes_to_mb(bytes):
|
||||||
@@ -180,7 +184,7 @@ def bytes_to_mb(bytes):
|
|||||||
|
|
||||||
|
|
||||||
def mb_to_bytes(mb_str):
|
def mb_to_bytes(mb_str):
|
||||||
result = re.search('^(\d+(?:\.\d+)?)\s?(?:mb)?', mb_str, flags=re.I)
|
result = re.search(r"^(\d+(?:\.\d+)?)\s?(?:mb)?", mb_str, flags=re.I)
|
||||||
if result:
|
if result:
|
||||||
return int(float(result.group(1)) * 1048576)
|
return int(float(result.group(1)) * 1048576)
|
||||||
|
|
||||||
@@ -249,9 +253,9 @@ def replace_all(text, dic):
|
|||||||
|
|
||||||
def replace_illegal_chars(string, type="file"):
|
def replace_illegal_chars(string, type="file"):
|
||||||
if type == "file":
|
if type == "file":
|
||||||
string = re.sub('[\?"*:|<>/]', '_', string)
|
string = re.sub(r"[\?\"*:|<>/]", "_", string)
|
||||||
if type == "folder":
|
if type == "folder":
|
||||||
string = re.sub('[:\?<>"|*]', '_', string)
|
string = re.sub(r"[:\?<>\"|*]", "_", string)
|
||||||
return string
|
return string
|
||||||
|
|
||||||
|
|
||||||
@@ -382,7 +386,7 @@ def clean_musicbrainz_name(s, return_as_string=True):
|
|||||||
|
|
||||||
|
|
||||||
def cleanTitle(title):
|
def cleanTitle(title):
|
||||||
title = re.sub('[\.\-\/\_]', ' ', title).lower()
|
title = re.sub(r"[\.\-\/\_]", " ", title).lower()
|
||||||
|
|
||||||
# Strip out extra whitespace
|
# Strip out extra whitespace
|
||||||
title = ' '.join(title.split())
|
title = ' '.join(title.split())
|
||||||
@@ -502,7 +506,7 @@ def path_match_patterns(path, patterns):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
for pattern in patterns:
|
for pattern in patterns:
|
||||||
if fnmatch.fnmatch(path, pattern):
|
if fnmatch(path, pattern):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# No match
|
# No match
|
||||||
@@ -708,7 +712,7 @@ def preserve_torrent_directory(albumpath, forced=False, single=False):
|
|||||||
workdir = os.path.join(tempdir, prefix)
|
workdir = os.path.join(tempdir, prefix)
|
||||||
workdir = re.sub(r'\[', '[[]', workdir)
|
workdir = re.sub(r'\[', '[[]', workdir)
|
||||||
workdir = re.sub(r'(?<!\[)\]', '[]]', workdir)
|
workdir = re.sub(r'(?<!\[)\]', '[]]', workdir)
|
||||||
if len(glob.glob(workdir + '*/')) >= 3:
|
if len(glob(workdir + '*/')) >= 3:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Looks like a temp directory has previously been created "
|
"Looks like a temp directory has previously been created "
|
||||||
"for this albumpath, not continuing "
|
"for this albumpath, not continuing "
|
||||||
@@ -856,6 +860,8 @@ def smartMove(src, dest, delete=True):
|
|||||||
try:
|
try:
|
||||||
os.rename(src, os.path.join(source_dir, newfile))
|
os.rename(src, os.path.join(source_dir, newfile))
|
||||||
filename = newfile
|
filename = newfile
|
||||||
|
source_path = os.path.join(source_dir, filename)
|
||||||
|
dest_path = os.path.join(dest, filename)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warn(f"Error renaming {src}: {e}")
|
logger.warn(f"Error renaming {src}: {e}")
|
||||||
break
|
break
|
||||||
@@ -1027,7 +1033,7 @@ class BeetsLogCapture(beetslogging.Handler):
|
|||||||
self.messages = []
|
self.messages = []
|
||||||
|
|
||||||
def emit(self, record):
|
def emit(self, record):
|
||||||
self.messages.append(six.text_type(record.msg))
|
self.messages.append(text_type(record.msg))
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
@@ -1039,3 +1045,17 @@ def capture_beets_log(logger='beets'):
|
|||||||
yield capture.messages
|
yield capture.messages
|
||||||
finally:
|
finally:
|
||||||
log.removeHandler(capture)
|
log.removeHandler(capture)
|
||||||
|
|
||||||
|
def have_pct_have_total(db_artist):
|
||||||
|
have_tracks = db_artist['HaveTracks'] or 0
|
||||||
|
total_tracks = db_artist['TotalTracks'] or 0
|
||||||
|
have_pct = have_tracks / total_tracks if total_tracks else 0
|
||||||
|
return (have_pct, total_tracks)
|
||||||
|
|
||||||
|
|
||||||
|
def has_token(title, token):
|
||||||
|
return bool(
|
||||||
|
re.search(rf'(?:\W|^)+{token}(?:\W|$)+',
|
||||||
|
title,
|
||||||
|
re.IGNORECASE | re.UNICODE)
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from .unittestcompat import TestCase
|
from .unittestcompat import TestCase
|
||||||
from headphones.helpers import clean_name
|
from headphones.helpers import clean_name, is_valid_date, age, has_token
|
||||||
|
|
||||||
|
|
||||||
class HelpersTest(TestCase):
|
class HelpersTest(TestCase):
|
||||||
@@ -46,3 +46,28 @@ class HelpersTest(TestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
test, expected, "check clean_name() with narrow non-ascii input"
|
test, expected, "check clean_name() with narrow non-ascii input"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_is_valid_date(date):
|
||||||
|
test_cases = [
|
||||||
|
('2021-11-12', True, "check is_valid_date returns True for valid date"),
|
||||||
|
(None, False, "check is_valid_date returns False for None"),
|
||||||
|
('2021-11', False, "check is_valid_date returns False for incomplete"),
|
||||||
|
('2021', False, "check is_valid_date returns False for incomplete")
|
||||||
|
]
|
||||||
|
for input, expected, desc in test_cases:
|
||||||
|
self.assertEqual(is_valid_date(input), expected, desc)
|
||||||
|
|
||||||
|
def test_has_token(self):
|
||||||
|
"""helpers: has_token()"""
|
||||||
|
self.assertEqual(
|
||||||
|
has_token("a cat ran", "cat"),
|
||||||
|
True,
|
||||||
|
"return True if token is in string"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
has_token("acatran", "cat"),
|
||||||
|
False,
|
||||||
|
"return False if token is part of another word"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+10
-20
@@ -102,12 +102,7 @@ def artistlist_to_mbids(artistlist, forced=False):
|
|||||||
myDB.action('DELETE from newartists WHERE ArtistName=?', [artist])
|
myDB.action('DELETE from newartists WHERE ArtistName=?', [artist])
|
||||||
|
|
||||||
# Update the similar artist tag cloud:
|
# Update the similar artist tag cloud:
|
||||||
logger.info('Updating artist information from Last.fm')
|
lastfm.getSimilar()
|
||||||
|
|
||||||
try:
|
|
||||||
lastfm.getSimilar()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warn('Failed to update artist information from Last.fm: %s' % e)
|
|
||||||
|
|
||||||
|
|
||||||
def addArtistIDListToDB(artistidlist):
|
def addArtistIDListToDB(artistidlist):
|
||||||
@@ -245,7 +240,7 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
|
|||||||
rgid = rg['id']
|
rgid = rg['id']
|
||||||
skip_log = 0
|
skip_log = 0
|
||||||
# Make a user configurable variable to skip update of albums with release dates older than this date (in days)
|
# Make a user configurable variable to skip update of albums with release dates older than this date (in days)
|
||||||
pause_delta = headphones.CONFIG.MB_IGNORE_AGE
|
ignore_age = headphones.CONFIG.MB_IGNORE_AGE
|
||||||
|
|
||||||
rg_exists = myDB.action("SELECT * from albums WHERE AlbumID=?", [rg['id']]).fetchone()
|
rg_exists = myDB.action("SELECT * from albums WHERE AlbumID=?", [rg['id']]).fetchone()
|
||||||
|
|
||||||
@@ -274,18 +269,18 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
|
|||||||
if len(check_release_date) == 10:
|
if len(check_release_date) == 10:
|
||||||
release_date = check_release_date
|
release_date = check_release_date
|
||||||
elif len(check_release_date) == 7:
|
elif len(check_release_date) == 7:
|
||||||
release_date = check_release_date + "-31"
|
release_date = check_release_date + "-27"
|
||||||
elif len(check_release_date) == 4:
|
elif len(check_release_date) == 4:
|
||||||
release_date = check_release_date + "-12-31"
|
release_date = check_release_date + "-12-27"
|
||||||
else:
|
else:
|
||||||
release_date = today
|
release_date = today
|
||||||
if helpers.get_age(today) - helpers.get_age(release_date) < pause_delta:
|
if helpers.age(release_date) < ignore_age:
|
||||||
logger.info("[%s] Now updating: %s (Release Date <%s Days)",
|
logger.info("[%s] Now updating: %s (Release Date <%s Days)",
|
||||||
artist['artist_name'], rg['title'], pause_delta)
|
artist['artist_name'], rg['title'], ignore_age)
|
||||||
new_releases = mb.get_new_releases(rgid, includeExtras, True)
|
new_releases = mb.get_new_releases(rgid, includeExtras, True)
|
||||||
else:
|
else:
|
||||||
logger.info("[%s] Skipping: %s (Release Date >%s Days)",
|
logger.info("[%s] Skipping: %s (Release Date >%s Days)",
|
||||||
artist['artist_name'], rg['title'], pause_delta)
|
artist['artist_name'], rg['title'], ignore_age)
|
||||||
skip_log = 1
|
skip_log = 1
|
||||||
new_releases = 0
|
new_releases = 0
|
||||||
|
|
||||||
@@ -450,14 +445,9 @@ def addArtisttoDB(artistid, extrasonly=False, forcefull=False, type="artist"):
|
|||||||
|
|
||||||
if headphones.CONFIG.AUTOWANT_ALL:
|
if headphones.CONFIG.AUTOWANT_ALL:
|
||||||
newValueDict['Status'] = "Wanted"
|
newValueDict['Status'] = "Wanted"
|
||||||
elif album['ReleaseDate'] > today and headphones.CONFIG.AUTOWANT_UPCOMING:
|
elif headphones.CONFIG.AUTOWANT_UPCOMING:
|
||||||
newValueDict['Status'] = "Wanted"
|
if helpers.is_valid_date(album['ReleaseDate']) and helpers.age(album['ReleaseDate']) < 21:
|
||||||
# Sometimes "new" albums are added to musicbrainz after their release date, so let's try to catch these
|
newValueDict['Status'] = "Wanted"
|
||||||
# The first test just makes sure we have year-month-day
|
|
||||||
elif helpers.get_age(album['ReleaseDate']) and helpers.get_age(
|
|
||||||
today) - helpers.get_age(
|
|
||||||
album['ReleaseDate']) < 21 and headphones.CONFIG.AUTOWANT_UPCOMING:
|
|
||||||
newValueDict['Status'] = "Wanted"
|
|
||||||
else:
|
else:
|
||||||
newValueDict['Status'] = "Skipped"
|
newValueDict['Status'] = "Skipped"
|
||||||
|
|
||||||
|
|||||||
+27
-20
@@ -23,7 +23,7 @@ from headphones import db, logger, request
|
|||||||
TIMEOUT = 60.0 # seconds
|
TIMEOUT = 60.0 # seconds
|
||||||
REQUEST_LIMIT = 1.0 / 5 # seconds
|
REQUEST_LIMIT = 1.0 / 5 # seconds
|
||||||
ENTRY_POINT = "https://ws.audioscrobbler.com/2.0/"
|
ENTRY_POINT = "https://ws.audioscrobbler.com/2.0/"
|
||||||
API_KEY = "395e6ec6bb557382fc41fde867bce66f"
|
APP_API_KEY = "395e6ec6bb557382fc41fde867bce66f"
|
||||||
|
|
||||||
# Required for API request limit
|
# Required for API request limit
|
||||||
lastfm_lock = headphones.lock.TimedLock(REQUEST_LIMIT)
|
lastfm_lock = headphones.lock.TimedLock(REQUEST_LIMIT)
|
||||||
@@ -31,7 +31,7 @@ lastfm_lock = headphones.lock.TimedLock(REQUEST_LIMIT)
|
|||||||
|
|
||||||
def request_lastfm(method, **kwargs):
|
def request_lastfm(method, **kwargs):
|
||||||
"""
|
"""
|
||||||
Call a Last.FM API method. Automatically sets the method and API key. Method
|
Call a Last.fm API method. Automatically sets the method and API key. Method
|
||||||
will return the result if no error occured.
|
will return the result if no error occured.
|
||||||
|
|
||||||
By default, this method will request the JSON format, since it is more
|
By default, this method will request the JSON format, since it is more
|
||||||
@@ -40,35 +40,42 @@ def request_lastfm(method, **kwargs):
|
|||||||
|
|
||||||
# Prepare request
|
# Prepare request
|
||||||
kwargs["method"] = method
|
kwargs["method"] = method
|
||||||
kwargs.setdefault("api_key", API_KEY)
|
kwargs.setdefault("api_key", headphones.CONFIG.LASTFM_APIKEY or APP_API_KEY)
|
||||||
kwargs.setdefault("format", "json")
|
kwargs.setdefault("format", "json")
|
||||||
|
|
||||||
# Send request
|
# Send request
|
||||||
logger.debug("Calling Last.FM method: %s", method)
|
logger.debug("Calling Last.fm method: %s", method)
|
||||||
logger.debug("Last.FM call parameters: %s", kwargs)
|
logger.debug("Last.fm call parameters: %s", kwargs)
|
||||||
|
|
||||||
data = request.request_json(ENTRY_POINT, timeout=TIMEOUT, params=kwargs, lock=lastfm_lock)
|
data = request.request_json(ENTRY_POINT, timeout=TIMEOUT, params=kwargs, lock=lastfm_lock)
|
||||||
|
|
||||||
# Parse response and check for errors.
|
# Parse response and check for errors.
|
||||||
if not data:
|
if not data:
|
||||||
logger.error("Error calling Last.FM method: %s", method)
|
logger.error("Error calling Last.fm method: %s", method)
|
||||||
return
|
return
|
||||||
|
|
||||||
if "error" in data:
|
if "error" in data:
|
||||||
logger.debug("Last.FM returned an error: %s", data["message"])
|
logger.debug("Last.fm returned an error: %s", data["message"])
|
||||||
return
|
return
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
def getSimilar():
|
def getSimilar():
|
||||||
myDB = db.DBConnection()
|
if not headphones.CONFIG.LASTFM_APIKEY:
|
||||||
results = myDB.select("SELECT ArtistID from artists ORDER BY HaveTracks DESC")
|
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
|
||||||
|
|
||||||
logger.info("Fetching similar artists from Last.FM for tag cloud")
|
myDB = db.DBConnection()
|
||||||
|
results = myDB.select("SELECT ArtistID from artists ORDER BY HaveTracks DESC LIMIT 10")
|
||||||
|
|
||||||
|
logger.info("Fetching similar artists from Last.fm for tag cloud")
|
||||||
artistlist = []
|
artistlist = []
|
||||||
|
|
||||||
for result in results[:12]:
|
for result in results:
|
||||||
data = request_lastfm("artist.getsimilar", mbid=result["ArtistId"])
|
data = request_lastfm("artist.getsimilar", mbid=result["ArtistId"])
|
||||||
|
|
||||||
if data and "similarartists" in data:
|
if data and "similarartists" in data:
|
||||||
@@ -85,7 +92,7 @@ def getSimilar():
|
|||||||
artistlist.append((artist_name, artist_mbid))
|
artistlist.append((artist_name, artist_mbid))
|
||||||
|
|
||||||
# Add new artists to tag cloud
|
# Add new artists to tag cloud
|
||||||
logger.debug("Fetched %d artists from Last.FM", len(artistlist))
|
logger.debug("Fetched %d artists from Last.fm", len(artistlist))
|
||||||
count = defaultdict(int)
|
count = defaultdict(int)
|
||||||
|
|
||||||
for artist, mbid in artistlist:
|
for artist, mbid in artistlist:
|
||||||
@@ -103,7 +110,7 @@ def getSimilar():
|
|||||||
|
|
||||||
myDB.action("INSERT INTO lastfmcloud VALUES( ?, ?, ?)", [artist_name, artist_mbid, count])
|
myDB.action("INSERT INTO lastfmcloud VALUES( ?, ?, ?)", [artist_name, artist_mbid, count])
|
||||||
|
|
||||||
logger.debug("Inserted %d artists into Last.FM tag cloud", len(top_list))
|
logger.debug("Inserted %d artists into Last.fm tag cloud", len(top_list))
|
||||||
|
|
||||||
|
|
||||||
def getArtists():
|
def getArtists():
|
||||||
@@ -111,16 +118,16 @@ def getArtists():
|
|||||||
results = myDB.select("SELECT ArtistID from artists")
|
results = myDB.select("SELECT ArtistID from artists")
|
||||||
|
|
||||||
if not headphones.CONFIG.LASTFM_USERNAME:
|
if not headphones.CONFIG.LASTFM_USERNAME:
|
||||||
logger.warn("Last.FM username not set, not importing artists.")
|
logger.warn("Last.fm username not set, not importing artists.")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("Fetching artists from Last.FM for username: %s", headphones.CONFIG.LASTFM_USERNAME)
|
logger.info("Fetching artists from Last.fm for username: %s", headphones.CONFIG.LASTFM_USERNAME)
|
||||||
data = request_lastfm("library.getartists", limit=1000, user=headphones.CONFIG.LASTFM_USERNAME)
|
data = request_lastfm("library.getartists", limit=1000, user=headphones.CONFIG.LASTFM_USERNAME)
|
||||||
|
|
||||||
if data and "artists" in data:
|
if data and "artists" in data:
|
||||||
artistlist = []
|
artistlist = []
|
||||||
artists = data["artists"]["artist"]
|
artists = data["artists"]["artist"]
|
||||||
logger.debug("Fetched %d artists from Last.FM", len(artists))
|
logger.debug("Fetched %d artists from Last.fm", len(artists))
|
||||||
|
|
||||||
for artist in artists:
|
for artist in artists:
|
||||||
artist_mbid = artist["mbid"]
|
artist_mbid = artist["mbid"]
|
||||||
@@ -133,20 +140,20 @@ def getArtists():
|
|||||||
for artistid in artistlist:
|
for artistid in artistlist:
|
||||||
importer.addArtisttoDB(artistid)
|
importer.addArtisttoDB(artistid)
|
||||||
|
|
||||||
logger.info("Imported %d new artists from Last.FM", len(artistlist))
|
logger.info("Imported %d new artists from Last.fm", len(artistlist))
|
||||||
|
|
||||||
|
|
||||||
def getTagTopArtists(tag, limit=50):
|
def getTagTopArtists(tag, limit=50):
|
||||||
myDB = db.DBConnection()
|
myDB = db.DBConnection()
|
||||||
results = myDB.select("SELECT ArtistID from artists")
|
results = myDB.select("SELECT ArtistID from artists")
|
||||||
|
|
||||||
logger.info("Fetching top artists from Last.FM for tag: %s", tag)
|
logger.info("Fetching top artists from Last.fm for tag: %s", tag)
|
||||||
data = request_lastfm("tag.gettopartists", limit=limit, tag=tag)
|
data = request_lastfm("tag.gettopartists", limit=limit, tag=tag)
|
||||||
|
|
||||||
if data and "topartists" in data:
|
if data and "topartists" in data:
|
||||||
artistlist = []
|
artistlist = []
|
||||||
artists = data["topartists"]["artist"]
|
artists = data["topartists"]["artist"]
|
||||||
logger.debug("Fetched %d artists from Last.FM", len(artists))
|
logger.debug("Fetched %d artists from Last.fm", len(artists))
|
||||||
|
|
||||||
for artist in artists:
|
for artist in artists:
|
||||||
try:
|
try:
|
||||||
@@ -162,4 +169,4 @@ def getTagTopArtists(tag, limit=50):
|
|||||||
for artistid in artistlist:
|
for artistid in artistlist:
|
||||||
importer.addArtisttoDB(artistid)
|
importer.addArtisttoDB(artistid)
|
||||||
|
|
||||||
logger.debug("Added %d new artists from Last.FM", len(artistlist))
|
logger.debug("Added %d new artists from Last.fm", len(artistlist))
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ def libraryScan(dir=None, append=False, ArtistID=None, ArtistName=None,
|
|||||||
if track['ArtistName']:
|
if track['ArtistName']:
|
||||||
# Make sure deleted files get accounted for when updating artist track counts
|
# Make sure deleted files get accounted for when updating artist track counts
|
||||||
new_artists.append(track['ArtistName'])
|
new_artists.append(track['ArtistName'])
|
||||||
myDB.action('DELETE FROM have WHERE Location=?', [Track['Location']])
|
myDB.action('DELETE FROM have WHERE Location=?', [track['Location']])
|
||||||
logger.info(
|
logger.info(
|
||||||
f"{Track['Location']} removed from Headphones, as it "
|
f"{track['Location']} removed from Headphones, as it "
|
||||||
f"is no longer on disk"
|
f"is no longer on disk"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+5
-11
@@ -14,20 +14,14 @@
|
|||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
from headphones import logger, db, helpers
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
import musicbrainzngs
|
||||||
|
|
||||||
import headphones
|
import headphones
|
||||||
import musicbrainzngs
|
|
||||||
import headphones.lock
|
import headphones.lock
|
||||||
|
from headphones import logger, db, helpers
|
||||||
|
|
||||||
try:
|
|
||||||
# pylint:disable=E0611
|
|
||||||
# ignore this error because we are catching the ImportError
|
|
||||||
from collections import OrderedDict
|
|
||||||
# pylint:enable=E0611
|
|
||||||
except ImportError:
|
|
||||||
# Python 2.6.x fallback, from libs
|
|
||||||
from ordereddict import OrderedDict
|
|
||||||
|
|
||||||
mb_lock = headphones.lock.TimedLock(0)
|
mb_lock = headphones.lock.TimedLock(0)
|
||||||
|
|
||||||
@@ -97,7 +91,7 @@ def findArtist(name, limit=1):
|
|||||||
try:
|
try:
|
||||||
artistResults = musicbrainzngs.search_artists(limit=limit, **criteria)['artist-list']
|
artistResults = musicbrainzngs.search_artists(limit=limit, **criteria)['artist-list']
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
if "at least one query term is required" in e.message:
|
if "at least one query term is required" in str(e):
|
||||||
logger.error(
|
logger.error(
|
||||||
"Tried to search without a term, or an empty one. Provided artist (probably emtpy): %s",
|
"Tried to search without a term, or an empty one. Provided artist (probably emtpy): %s",
|
||||||
name)
|
name)
|
||||||
|
|||||||
+10
-2
@@ -79,6 +79,7 @@ class Vars:
|
|||||||
Metadata $variable names (only ones set explicitly by headphones).
|
Metadata $variable names (only ones set explicitly by headphones).
|
||||||
"""
|
"""
|
||||||
DISC = '$Disc'
|
DISC = '$Disc'
|
||||||
|
DISC_TOTAL = '$DiscTotal'
|
||||||
TRACK = '$Track'
|
TRACK = '$Track'
|
||||||
TITLE = '$Title'
|
TITLE = '$Title'
|
||||||
ARTIST = '$Artist'
|
ARTIST = '$Artist'
|
||||||
@@ -171,7 +172,7 @@ def _lower(s):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def file_metadata(path, release):
|
def file_metadata(path, release, single_disc_ignore=False):
|
||||||
# type: (str,sqlite3.Row)->Tuple[Mapping[str,str],bool]
|
# type: (str,sqlite3.Row)->Tuple[Mapping[str,str],bool]
|
||||||
"""
|
"""
|
||||||
Prepare metadata dictionary for path substitution, based on file name,
|
Prepare metadata dictionary for path substitution, based on file name,
|
||||||
@@ -194,7 +195,13 @@ def file_metadata(path, release):
|
|||||||
_row_to_dict(release, res)
|
_row_to_dict(release, res)
|
||||||
|
|
||||||
date, year = _date_year(release)
|
date, year = _date_year(release)
|
||||||
if not f.disc:
|
|
||||||
|
if not f.disctotal or (f.disctotal == 1 and single_disc_ignore):
|
||||||
|
disc_total = ''
|
||||||
|
else:
|
||||||
|
disc_total = '%d' % f.disctotal
|
||||||
|
|
||||||
|
if not f.disc or (f.disctotal == 1 and single_disc_ignore):
|
||||||
disc_number = ''
|
disc_number = ''
|
||||||
else:
|
else:
|
||||||
disc_number = '%d' % f.disc
|
disc_number = '%d' % f.disc
|
||||||
@@ -226,6 +233,7 @@ def file_metadata(path, release):
|
|||||||
album_title = release['AlbumTitle']
|
album_title = release['AlbumTitle']
|
||||||
override_values = {
|
override_values = {
|
||||||
Vars.DISC: disc_number,
|
Vars.DISC: disc_number,
|
||||||
|
Vars.DISC_TOTAL: disc_total,
|
||||||
Vars.TRACK: track_number,
|
Vars.TRACK: track_number,
|
||||||
Vars.TITLE: title,
|
Vars.TITLE: title,
|
||||||
Vars.ARTIST: artist_name,
|
Vars.ARTIST: artist_name,
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ from . import getXldProfile
|
|||||||
|
|
||||||
|
|
||||||
def encode(albumPath):
|
def encode(albumPath):
|
||||||
print(albumPath)
|
|
||||||
use_xld = headphones.CONFIG.ENCODER == 'xld'
|
use_xld = headphones.CONFIG.ENCODER == 'xld'
|
||||||
|
|
||||||
# Return if xld details not found
|
# Return if xld details not found
|
||||||
@@ -161,6 +160,15 @@ def encode(albumPath):
|
|||||||
|
|
||||||
# Use multicore if enabled
|
# Use multicore if enabled
|
||||||
if headphones.CONFIG.ENCODER_MULTICORE:
|
if headphones.CONFIG.ENCODER_MULTICORE:
|
||||||
|
|
||||||
|
# Set macOS multiprocessing method
|
||||||
|
try:
|
||||||
|
if headphones.SYS_PLATFORM == "darwin":
|
||||||
|
multiprocessing.set_start_method('fork')
|
||||||
|
except RuntimeError:
|
||||||
|
# Already set, ignore
|
||||||
|
pass
|
||||||
|
|
||||||
if headphones.CONFIG.ENCODER_MULTICORE_COUNT == 0:
|
if headphones.CONFIG.ENCODER_MULTICORE_COUNT == 0:
|
||||||
processes = multiprocessing.cpu_count()
|
processes = multiprocessing.cpu_count()
|
||||||
else:
|
else:
|
||||||
|
|||||||
+4
-185
@@ -15,98 +15,10 @@ from headphones import logger, helpers, common, request
|
|||||||
from pynma import pynma
|
from pynma import pynma
|
||||||
import cherrypy
|
import cherrypy
|
||||||
import headphones
|
import headphones
|
||||||
import gntp.notifier
|
|
||||||
#import oauth2 as oauth
|
#import oauth2 as oauth
|
||||||
import twitter
|
import twitter
|
||||||
|
|
||||||
|
|
||||||
class GROWL(object):
|
|
||||||
"""
|
|
||||||
Growl notifications, for OS X.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.enabled = headphones.CONFIG.GROWL_ENABLED
|
|
||||||
self.host = headphones.CONFIG.GROWL_HOST
|
|
||||||
self.password = headphones.CONFIG.GROWL_PASSWORD
|
|
||||||
|
|
||||||
def conf(self, options):
|
|
||||||
return cherrypy.config['config'].get('Growl', options)
|
|
||||||
|
|
||||||
def notify(self, message, event):
|
|
||||||
if not self.enabled:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Split host and port
|
|
||||||
if self.host == "":
|
|
||||||
host, port = "localhost", 23053
|
|
||||||
|
|
||||||
if ":" in self.host:
|
|
||||||
host, port = self.host.split(':', 1)
|
|
||||||
port = int(port)
|
|
||||||
else:
|
|
||||||
host, port = self.host, 23053
|
|
||||||
|
|
||||||
# If password is empty, assume none
|
|
||||||
if self.password == "":
|
|
||||||
password = None
|
|
||||||
else:
|
|
||||||
password = self.password
|
|
||||||
|
|
||||||
# Register notification
|
|
||||||
growl = gntp.notifier.GrowlNotifier(
|
|
||||||
applicationName='Headphones',
|
|
||||||
notifications=['New Event'],
|
|
||||||
defaultNotifications=['New Event'],
|
|
||||||
hostname=host,
|
|
||||||
port=port,
|
|
||||||
password=password
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
growl.register()
|
|
||||||
except gntp.notifier.errors.NetworkError:
|
|
||||||
logger.warning('Growl notification failed: network error')
|
|
||||||
return
|
|
||||||
except gntp.notifier.errors.AuthError:
|
|
||||||
logger.warning('Growl notification failed: authentication error')
|
|
||||||
return
|
|
||||||
|
|
||||||
# Fix message
|
|
||||||
message = message.encode(headphones.SYS_ENCODING, "replace")
|
|
||||||
|
|
||||||
# Send it, including an image
|
|
||||||
image_file = os.path.join(str(headphones.PROG_DIR),
|
|
||||||
"data/images/headphoneslogo.png")
|
|
||||||
|
|
||||||
with open(image_file, 'rb') as f:
|
|
||||||
image = f.read()
|
|
||||||
|
|
||||||
try:
|
|
||||||
growl.notify(
|
|
||||||
noteType='New Event',
|
|
||||||
title=event,
|
|
||||||
description=message,
|
|
||||||
icon=image
|
|
||||||
)
|
|
||||||
except gntp.notifier.errors.NetworkError:
|
|
||||||
logger.warning('Growl notification failed: network error')
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Growl notifications sent.")
|
|
||||||
|
|
||||||
def updateLibrary(self):
|
|
||||||
# For uniformity reasons not removed
|
|
||||||
return
|
|
||||||
|
|
||||||
def test(self, host, password):
|
|
||||||
self.enabled = True
|
|
||||||
self.host = host
|
|
||||||
self.password = password
|
|
||||||
|
|
||||||
self.notify('ZOMG Lazors Pewpewpew!', 'Test Message')
|
|
||||||
|
|
||||||
|
|
||||||
class PROWL(object):
|
class PROWL(object):
|
||||||
"""
|
"""
|
||||||
Prowl notifications.
|
Prowl notifications.
|
||||||
@@ -840,105 +752,12 @@ class TwitterNotifier(object):
|
|||||||
|
|
||||||
|
|
||||||
class OSX_NOTIFY(object):
|
class OSX_NOTIFY(object):
|
||||||
def __init__(self):
|
def notify(self, title, subtitle):
|
||||||
try:
|
try:
|
||||||
self.objc = __import__("objc")
|
script = f'display notification "{subtitle}" with title "{title}"'
|
||||||
self.AppKit = __import__("AppKit")
|
subprocess.run(["osascript", "-e", script])
|
||||||
except:
|
|
||||||
logger.warn('OS X Notification: Cannot import objc or AppKit')
|
|
||||||
pass
|
|
||||||
|
|
||||||
def swizzle(self, cls, SEL, func):
|
|
||||||
old_IMP = getattr(cls, SEL, None)
|
|
||||||
if old_IMP is None:
|
|
||||||
old_IMP = cls.instanceMethodForSelector_(SEL)
|
|
||||||
|
|
||||||
def wrapper(self, *args, **kwargs):
|
|
||||||
return func(self, old_IMP, *args, **kwargs)
|
|
||||||
|
|
||||||
new_IMP = self.objc.selector(
|
|
||||||
wrapper,
|
|
||||||
selector=old_IMP.selector,
|
|
||||||
signature=old_IMP.signature
|
|
||||||
)
|
|
||||||
self.objc.classAddMethod(cls, SEL.encode(), new_IMP)
|
|
||||||
|
|
||||||
def notify(self, title, subtitle=None, text=None, sound=True, image=None):
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.swizzle(
|
|
||||||
self.objc.lookUpClass('NSBundle'),
|
|
||||||
'bundleIdentifier',
|
|
||||||
self.swizzled_bundleIdentifier
|
|
||||||
)
|
|
||||||
|
|
||||||
NSUserNotification = self.objc.lookUpClass('NSUserNotification')
|
|
||||||
NSUserNotificationCenter = self.objc.lookUpClass(
|
|
||||||
'NSUserNotificationCenter')
|
|
||||||
NSAutoreleasePool = self.objc.lookUpClass('NSAutoreleasePool')
|
|
||||||
|
|
||||||
if not NSUserNotification or not NSUserNotificationCenter:
|
|
||||||
return False
|
|
||||||
|
|
||||||
pool = NSAutoreleasePool.alloc().init()
|
|
||||||
|
|
||||||
notification = NSUserNotification.alloc().init()
|
|
||||||
notification.setTitle_(title)
|
|
||||||
if subtitle:
|
|
||||||
notification.setSubtitle_(subtitle)
|
|
||||||
if text:
|
|
||||||
notification.setInformativeText_(text)
|
|
||||||
if sound:
|
|
||||||
notification.setSoundName_(
|
|
||||||
"NSUserNotificationDefaultSoundName")
|
|
||||||
if image:
|
|
||||||
source_img = self.AppKit.NSImage.alloc().\
|
|
||||||
initByReferencingFile_(image)
|
|
||||||
notification.setContentImage_(source_img)
|
|
||||||
# notification.set_identityImage_(source_img)
|
|
||||||
notification.setHasActionButton_(False)
|
|
||||||
|
|
||||||
notification_center = NSUserNotificationCenter.\
|
|
||||||
defaultUserNotificationCenter()
|
|
||||||
notification_center.deliverNotification_(notification)
|
|
||||||
|
|
||||||
del pool
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warn('Error sending OS X Notification: %s' % e)
|
logger.warn(f"Error sending MacOS Notification: {e}")
|
||||||
return False
|
|
||||||
|
|
||||||
def swizzled_bundleIdentifier(self, original, swizzled):
|
|
||||||
return 'ade.headphones.osxnotify'
|
|
||||||
|
|
||||||
|
|
||||||
class BOXCAR(object):
|
|
||||||
def __init__(self):
|
|
||||||
self.url = 'https://new.boxcar.io/api/notifications'
|
|
||||||
|
|
||||||
def notify(self, title, message, rgid=None):
|
|
||||||
try:
|
|
||||||
if rgid:
|
|
||||||
message += '<br></br><a href="https://musicbrainz.org/' \
|
|
||||||
'release-group/%s">MusicBrainz</a>' % rgid
|
|
||||||
|
|
||||||
data = urllib.parse.urlencode({
|
|
||||||
'user_credentials': headphones.CONFIG.BOXCAR_TOKEN,
|
|
||||||
'notification[title]': title.encode('utf-8'),
|
|
||||||
'notification[long_message]': message.encode('utf-8'),
|
|
||||||
'notification[sound]': "done",
|
|
||||||
'notification[icon_url]': "https://raw.githubusercontent.com/rembo10/headphones/master/data/images"
|
|
||||||
"/headphoneslogo.png"
|
|
||||||
})
|
|
||||||
|
|
||||||
req = urllib.request.Request(self.url)
|
|
||||||
handle = urllib.request.urlopen(req, data)
|
|
||||||
handle.close()
|
|
||||||
return True
|
|
||||||
|
|
||||||
except urllib.error.URLError as e:
|
|
||||||
logger.warn('Error sending Boxcar2 Notification: %s' % e)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ def sendNZB(nzb):
|
|||||||
nzbcontent64 = None
|
nzbcontent64 = None
|
||||||
if nzb.resultType == "nzbdata":
|
if nzb.resultType == "nzbdata":
|
||||||
data = nzb.extraInfo[0]
|
data = nzb.extraInfo[0]
|
||||||
nzbcontent64 = standard_b64encode(data)
|
# NZBGet needs a string, not bytes
|
||||||
|
nzbcontent64 = standard_b64encode(data).decode("utf-8")
|
||||||
|
|
||||||
logger.info("Sending NZB to NZBget")
|
logger.info("Sending NZB to NZBget")
|
||||||
logger.debug("URL: " + url)
|
logger.debug("URL: " + url)
|
||||||
|
|||||||
+52
-34
@@ -27,7 +27,7 @@ from beets import config as beetsconfig
|
|||||||
from beets import logging as beetslogging
|
from beets import logging as beetslogging
|
||||||
from mediafile import MediaFile, FileTypeError, UnreadableFileError
|
from mediafile import MediaFile, FileTypeError, UnreadableFileError
|
||||||
from beetsplug import lyrics as beetslyrics
|
from beetsplug import lyrics as beetslyrics
|
||||||
from headphones import notifiers, utorrent, transmission, deluge, qbittorrent
|
from headphones import notifiers, utorrent, transmission, deluge, qbittorrent, soulseek
|
||||||
from headphones import db, albumart, librarysync
|
from headphones import db, albumart, librarysync
|
||||||
from headphones import logger, helpers, mb, music_encoder
|
from headphones import logger, helpers, mb, music_encoder
|
||||||
from headphones import metadata
|
from headphones import metadata
|
||||||
@@ -36,18 +36,45 @@ postprocessor_lock = threading.Lock()
|
|||||||
|
|
||||||
|
|
||||||
def checkFolder():
|
def checkFolder():
|
||||||
logger.debug("Checking download folder for completed downloads (only snatched ones).")
|
logger.info("Checking download folder for completed downloads (only snatched ones).")
|
||||||
|
|
||||||
with postprocessor_lock:
|
with postprocessor_lock:
|
||||||
myDB = db.DBConnection()
|
myDB = db.DBConnection()
|
||||||
snatched = myDB.select('SELECT * from snatched WHERE Status="Snatched"')
|
snatched = myDB.select('SELECT * from snatched WHERE Status="Snatched"')
|
||||||
|
|
||||||
for album in snatched:
|
for album in snatched:
|
||||||
if album['FolderName']:
|
if album['FolderName']:
|
||||||
folder_name = album['FolderName']
|
folder_name = album['FolderName']
|
||||||
single = False
|
single = False
|
||||||
if album['Kind'] == 'nzb':
|
|
||||||
|
# Soulseek, check download complete or errored
|
||||||
|
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
|
download_dir = headphones.CONFIG.DOWNLOAD_DIR
|
||||||
|
elif album['Kind'] == 'bandcamp':
|
||||||
|
download_dir = headphones.CONFIG.BANDCAMP_DIR
|
||||||
else:
|
else:
|
||||||
if headphones.CONFIG.DELUGE_DONE_DIRECTORY and headphones.CONFIG.TORRENT_DOWNLOADER == 3:
|
if headphones.CONFIG.DELUGE_DONE_DIRECTORY and headphones.CONFIG.TORRENT_DOWNLOADER == 3:
|
||||||
download_dir = headphones.CONFIG.DELUGE_DONE_DIRECTORY
|
download_dir = headphones.CONFIG.DELUGE_DONE_DIRECTORY
|
||||||
@@ -65,7 +92,6 @@ def checkFolder():
|
|||||||
folder_name = torrent_folder_name
|
folder_name = torrent_folder_name
|
||||||
|
|
||||||
if folder_name:
|
if folder_name:
|
||||||
print(folder_name)
|
|
||||||
album_path = os.path.join(download_dir, folder_name)
|
album_path = os.path.join(download_dir, folder_name)
|
||||||
logger.debug("Checking if %s exists" % album_path)
|
logger.debug("Checking if %s exists" % album_path)
|
||||||
|
|
||||||
@@ -80,7 +106,6 @@ def checkFolder():
|
|||||||
|
|
||||||
|
|
||||||
def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=False, single=False):
|
def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=False, single=False):
|
||||||
print(albumpath)
|
|
||||||
myDB = db.DBConnection()
|
myDB = db.DBConnection()
|
||||||
release = myDB.action('SELECT * from albums WHERE AlbumID=?', [albumid]).fetchone()
|
release = myDB.action('SELECT * from albums WHERE AlbumID=?', [albumid]).fetchone()
|
||||||
tracks = myDB.select('SELECT * from tracks WHERE AlbumID=?', [albumid])
|
tracks = myDB.select('SELECT * from tracks WHERE AlbumID=?', [albumid])
|
||||||
@@ -291,7 +316,7 @@ def verify(albumid, albumpath, Kind=None, forced=False, keep_original_folder=Fal
|
|||||||
logger.debug('Metadata check failed. Verifying filenames...')
|
logger.debug('Metadata check failed. Verifying filenames...')
|
||||||
for downloaded_track in downloaded_track_list:
|
for downloaded_track in downloaded_track_list:
|
||||||
track_name = os.path.splitext(downloaded_track)[0]
|
track_name = os.path.splitext(downloaded_track)[0]
|
||||||
split_track_name = re.sub('[\.\-\_]', ' ', track_name).lower()
|
split_track_name = re.sub(r'[\.\-\_]', r' ', track_name).lower()
|
||||||
for track in tracks:
|
for track in tracks:
|
||||||
|
|
||||||
if not track['TrackTitle']:
|
if not track['TrackTitle']:
|
||||||
@@ -525,11 +550,6 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
|
|||||||
pushmessage = release['ArtistName'] + ' - ' + release['AlbumTitle']
|
pushmessage = release['ArtistName'] + ' - ' + release['AlbumTitle']
|
||||||
statusmessage = "Download and Postprocessing completed"
|
statusmessage = "Download and Postprocessing completed"
|
||||||
|
|
||||||
if headphones.CONFIG.GROWL_ENABLED:
|
|
||||||
logger.info("Growl request")
|
|
||||||
growl = notifiers.GROWL()
|
|
||||||
growl.notify(pushmessage, statusmessage)
|
|
||||||
|
|
||||||
if headphones.CONFIG.PROWL_ENABLED:
|
if headphones.CONFIG.PROWL_ENABLED:
|
||||||
logger.info("Prowl request")
|
logger.info("Prowl request")
|
||||||
prowl = notifiers.PROWL()
|
prowl = notifiers.PROWL()
|
||||||
@@ -598,21 +618,9 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
|
|||||||
#twitter.notify_download(pushmessage)
|
#twitter.notify_download(pushmessage)
|
||||||
|
|
||||||
if headphones.CONFIG.OSX_NOTIFY_ENABLED:
|
if headphones.CONFIG.OSX_NOTIFY_ENABLED:
|
||||||
from headphones import cache
|
logger.info("Sending MacOS notification")
|
||||||
c = cache.Cache()
|
osx = notifiers.OSX_NOTIFY()
|
||||||
album_art = c.get_artwork_from_cache(None, release['AlbumID'])
|
osx.notify(f"Headphones Processed", f"{pushmessage}\n{statusmessage}")
|
||||||
logger.info("Sending OS X notification")
|
|
||||||
osx_notify = notifiers.OSX_NOTIFY()
|
|
||||||
osx_notify.notify(release['ArtistName'],
|
|
||||||
release['AlbumTitle'],
|
|
||||||
statusmessage,
|
|
||||||
image=album_art)
|
|
||||||
|
|
||||||
if headphones.CONFIG.BOXCAR_ENABLED:
|
|
||||||
logger.info("Sending Boxcar2 notification")
|
|
||||||
boxcar = notifiers.BOXCAR()
|
|
||||||
boxcar.notify('Headphones processed: ' + pushmessage,
|
|
||||||
statusmessage, release['AlbumID'])
|
|
||||||
|
|
||||||
if headphones.CONFIG.SUBSONIC_ENABLED:
|
if headphones.CONFIG.SUBSONIC_ENABLED:
|
||||||
logger.info("Sending Subsonic update")
|
logger.info("Sending Subsonic update")
|
||||||
@@ -1087,7 +1095,11 @@ def renameFiles(albumpath, downloaded_track_list, release):
|
|||||||
# Until tagging works better I'm going to rely on the already provided metadata
|
# Until tagging works better I'm going to rely on the already provided metadata
|
||||||
|
|
||||||
for downloaded_track in downloaded_track_list:
|
for downloaded_track in downloaded_track_list:
|
||||||
md, from_metadata = metadata.file_metadata(downloaded_track, release)
|
md, from_metadata = metadata.file_metadata(
|
||||||
|
downloaded_track,
|
||||||
|
release,
|
||||||
|
headphones.CONFIG.RENAME_SINGLE_DISC_IGNORE
|
||||||
|
)
|
||||||
if md is None:
|
if md is None:
|
||||||
# unable to parse media file, skip file
|
# unable to parse media file, skip file
|
||||||
continue
|
continue
|
||||||
@@ -1166,10 +1178,15 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
|
|||||||
|
|
||||||
if dir:
|
if dir:
|
||||||
download_dirs.append(dir)
|
download_dirs.append(dir)
|
||||||
if headphones.CONFIG.DOWNLOAD_DIR and not dir:
|
else:
|
||||||
download_dirs.append(headphones.CONFIG.DOWNLOAD_DIR)
|
if headphones.CONFIG.DOWNLOAD_DIR:
|
||||||
if headphones.CONFIG.DOWNLOAD_TORRENT_DIR and not dir:
|
download_dirs.append(headphones.CONFIG.DOWNLOAD_DIR)
|
||||||
download_dirs.append(headphones.CONFIG.DOWNLOAD_TORRENT_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 DOWNLOAD_DIR and DOWNLOAD_TORRENT_DIR are the same, remove the duplicate to prevent us from trying to process the same folder twice.
|
# If DOWNLOAD_DIR and DOWNLOAD_TORRENT_DIR are the same, remove the duplicate to prevent us from trying to process the same folder twice.
|
||||||
download_dirs = list(set(download_dirs))
|
download_dirs = list(set(download_dirs))
|
||||||
@@ -1179,6 +1196,7 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
|
|||||||
folders = []
|
folders = []
|
||||||
|
|
||||||
for download_dir in download_dirs:
|
for download_dir in download_dirs:
|
||||||
|
download_dir = download_dir.encode(headphones.SYS_ENCODING, 'replace')
|
||||||
if not os.path.isdir(download_dir):
|
if not os.path.isdir(download_dir):
|
||||||
logger.warn('Directory %s does not exist. Skipping', download_dir)
|
logger.warn('Directory %s does not exist. Skipping', download_dir)
|
||||||
continue
|
continue
|
||||||
@@ -1196,9 +1214,9 @@ def forcePostProcess(dir=None, expand_subfolders=True, album_dir=None, keep_orig
|
|||||||
subfolders = helpers.expand_subfolders(path_to_folder)
|
subfolders = helpers.expand_subfolders(path_to_folder)
|
||||||
|
|
||||||
if expand_subfolders and subfolders is not None:
|
if expand_subfolders and subfolders is not None:
|
||||||
folders.extend(subfolders)
|
folders.extend(subfolders.decode(headphones.SYS_ENCODING, 'replace'))
|
||||||
else:
|
else:
|
||||||
folders.append(path_to_folder)
|
folders.append(path_to_folder.decode(headphones.SYS_ENCODING, 'replace'))
|
||||||
|
|
||||||
# Log number of folders
|
# Log number of folders
|
||||||
if folders:
|
if folders:
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ from headphones import logger
|
|||||||
import feedparser
|
import feedparser
|
||||||
import headphones
|
import headphones
|
||||||
import headphones.lock
|
import headphones.lock
|
||||||
|
from bs4.builder import XMLParsedAsHTMLWarning
|
||||||
|
import warnings
|
||||||
|
warnings.filterwarnings("ignore", category=XMLParsedAsHTMLWarning)
|
||||||
|
|
||||||
|
|
||||||
# Disable SSL certificate warnings. We have our own handling
|
# Disable SSL certificate warnings. We have our own handling
|
||||||
@@ -245,7 +248,9 @@ def server_message(response):
|
|||||||
|
|
||||||
if message:
|
if message:
|
||||||
# Truncate message if it is too long.
|
# Truncate message if it is too long.
|
||||||
if len(message) > 150:
|
if len(message) > 200:
|
||||||
message = message[:150] + "..."
|
if not type(message) == str:
|
||||||
|
message = message.decode(headphones.SYS_ENCODING, 'replace')
|
||||||
|
message = message[:200] + "..."
|
||||||
|
|
||||||
logger.debug("Server responded with message: %s", message)
|
logger.debug("Server responded with message: %s", message)
|
||||||
|
|||||||
+16
-6
@@ -11,6 +11,7 @@ from bs4 import BeautifulSoup
|
|||||||
|
|
||||||
import headphones
|
import headphones
|
||||||
from headphones import logger
|
from headphones import logger
|
||||||
|
from headphones.types import Result
|
||||||
|
|
||||||
|
|
||||||
class Rutracker(object):
|
class Rutracker(object):
|
||||||
@@ -41,19 +42,22 @@ class Rutracker(object):
|
|||||||
'login_password': headphones.CONFIG.RUTRACKER_PASSWORD,
|
'login_password': headphones.CONFIG.RUTRACKER_PASSWORD,
|
||||||
'login': b'\xc2\xf5\xee\xe4' # '%C2%F5%EE%E4'
|
'login': b'\xc2\xf5\xee\xe4' # '%C2%F5%EE%E4'
|
||||||
}
|
}
|
||||||
|
headers = {
|
||||||
|
'User-Agent' : 'Headphones'
|
||||||
|
}
|
||||||
|
|
||||||
logger.info("Attempting to log in to rutracker...")
|
logger.info("Attempting to log in to rutracker...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False)
|
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False, headers=headers)
|
||||||
# try again
|
# try again
|
||||||
if not self.has_bb_session_cookie(r):
|
if not self.has_bb_session_cookie(r):
|
||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
if headphones.CONFIG.RUTRACKER_COOKIE:
|
if headphones.CONFIG.RUTRACKER_COOKIE:
|
||||||
logger.info("Attempting to log in using predefined cookie...")
|
logger.info("Attempting to log in using predefined cookie...")
|
||||||
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False, cookies={'bb_session': headphones.CONFIG.RUTRACKER_COOKIE})
|
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False, headers=headers, cookies={'bb_session': headphones.CONFIG.RUTRACKER_COOKIE})
|
||||||
else:
|
else:
|
||||||
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False)
|
r = self.session.post(loginpage, data=post_params, timeout=self.timeout, allow_redirects=False, headers=headers)
|
||||||
if self.has_bb_session_cookie(r):
|
if self.has_bb_session_cookie(r):
|
||||||
self.loggedin = True
|
self.loggedin = True
|
||||||
logger.info("Successfully logged in to rutracker")
|
logger.info("Successfully logged in to rutracker")
|
||||||
@@ -112,7 +116,10 @@ class Rutracker(object):
|
|||||||
Parse the search results and return valid torrent list
|
Parse the search results and return valid torrent list
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
headers = {'Referer': self.search_referer}
|
headers = {
|
||||||
|
'Referer': self.search_referer,
|
||||||
|
'User-Agent' : 'Headphones'
|
||||||
|
}
|
||||||
r = self.session.get(url=searchurl, headers=headers, timeout=self.timeout)
|
r = self.session.get(url=searchurl, headers=headers, timeout=self.timeout)
|
||||||
soup = BeautifulSoup(r.content, 'html.parser')
|
soup = BeautifulSoup(r.content, 'html.parser')
|
||||||
|
|
||||||
@@ -160,7 +167,7 @@ class Rutracker(object):
|
|||||||
torrent_id = dict([part.split('=') for part in urlparse(url)[4].split('&')])[
|
torrent_id = dict([part.split('=') for part in urlparse(url)[4].split('&')])[
|
||||||
't']
|
't']
|
||||||
topicurl = 'https://rutracker.org/forum/viewtopic.php?t=' + torrent_id
|
topicurl = 'https://rutracker.org/forum/viewtopic.php?t=' + torrent_id
|
||||||
rulist.append((title, size, topicurl, 'rutracker.org', 'torrent', True))
|
rulist.append(Result(title, size, url, 'rutracker.org', 'torrent', True))
|
||||||
else:
|
else:
|
||||||
logger.info("%s is larger than the maxsize or has too little seeders for this category, "
|
logger.info("%s is larger than the maxsize or has too little seeders for this category, "
|
||||||
"skipping. (Size: %i bytes, Seeders: %i)" % (title, size, int(seeds)))
|
"skipping. (Size: %i bytes, Seeders: %i)" % (title, size, int(seeds)))
|
||||||
@@ -182,7 +189,10 @@ class Rutracker(object):
|
|||||||
downloadurl = 'https://rutracker.org/forum/dl.php?t=' + torrent_id
|
downloadurl = 'https://rutracker.org/forum/dl.php?t=' + torrent_id
|
||||||
cookie = {'bb_dl': torrent_id}
|
cookie = {'bb_dl': torrent_id}
|
||||||
try:
|
try:
|
||||||
headers = {'Referer': url}
|
headers = {
|
||||||
|
'Referer': url,
|
||||||
|
'User-Agent' : 'Headphones'
|
||||||
|
}
|
||||||
r = self.session.post(url=downloadurl, cookies=cookie, headers=headers,
|
r = self.session.post(url=downloadurl, cookies=cookie, headers=headers,
|
||||||
timeout=self.timeout)
|
timeout=self.timeout)
|
||||||
return r.content
|
return r.content
|
||||||
|
|||||||
+698
-636
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,252 @@
|
|||||||
|
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 time
|
||||||
import json
|
import json
|
||||||
import base64
|
from base64 import b64encode
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -36,10 +36,10 @@ def addTorrent(link, data=None):
|
|||||||
|
|
||||||
if link.endswith('.torrent') and not link.startswith(('http', 'magnet')) or data:
|
if link.endswith('.torrent') and not link.startswith(('http', 'magnet')) or data:
|
||||||
if data:
|
if data:
|
||||||
metainfo = str(base64.b64encode(data))
|
metainfo = b64encode(data).decode("utf-8")
|
||||||
else:
|
else:
|
||||||
with open(link, 'rb') as f:
|
with open(link, 'rb') as f:
|
||||||
metainfo = str(base64.b64encode(f.read()))
|
metainfo = b64encode(f.read()).decode("utf-8")
|
||||||
arguments = {'metainfo': metainfo, 'download-dir': headphones.CONFIG.DOWNLOAD_TORRENT_DIR}
|
arguments = {'metainfo': metainfo, 'download-dir': headphones.CONFIG.DOWNLOAD_TORRENT_DIR}
|
||||||
else:
|
else:
|
||||||
arguments = {'filename': link, 'download-dir': headphones.CONFIG.DOWNLOAD_TORRENT_DIR}
|
arguments = {'filename': link, 'download-dir': headphones.CONFIG.DOWNLOAD_TORRENT_DIR}
|
||||||
@@ -205,5 +205,4 @@ def torrentAction(method, arguments):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
resp_json = response.json()
|
resp_json = response.json()
|
||||||
print(resp_json)
|
|
||||||
return resp_json
|
return resp_json
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Result:
|
||||||
|
title: str
|
||||||
|
size: int
|
||||||
|
url: str
|
||||||
|
provider: str
|
||||||
|
kind: str
|
||||||
|
matches: bool
|
||||||
+80
-101
@@ -15,34 +15,46 @@
|
|||||||
|
|
||||||
# NZBGet support added by CurlyMo <curlymoo1@gmail.com> as a part of XBian - XBMC on the Raspberry Pi
|
# NZBGet support added by CurlyMo <curlymoo1@gmail.com> as a part of XBian - XBMC on the Raspberry Pi
|
||||||
|
|
||||||
from operator import itemgetter
|
|
||||||
import threading
|
|
||||||
import secrets
|
|
||||||
import random
|
|
||||||
import urllib.request, urllib.parse, urllib.error
|
|
||||||
import json
|
import json
|
||||||
import time
|
|
||||||
import sys
|
|
||||||
from html import escape as html_escape
|
|
||||||
import urllib.request, urllib.error, urllib.parse
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
from headphones import logger, searcher, db, importer, mb, lastfm, librarysync, helpers, notifiers, crier
|
import secrets
|
||||||
from headphones.helpers import checked, radio, today, clean_name
|
import sys
|
||||||
from mako.lookup import TemplateLookup
|
import threading
|
||||||
from mako import exceptions
|
import time
|
||||||
import headphones
|
from collections import OrderedDict
|
||||||
import cherrypy
|
from dataclasses import asdict
|
||||||
|
from html import escape as html_escape
|
||||||
|
from operator import itemgetter
|
||||||
|
from urllib import parse
|
||||||
|
|
||||||
try:
|
import cherrypy
|
||||||
# pylint:disable=E0611
|
from mako import exceptions
|
||||||
# ignore this error because we are catching the ImportError
|
from mako.lookup import TemplateLookup
|
||||||
from collections import OrderedDict
|
|
||||||
# pylint:enable=E0611
|
import headphones
|
||||||
except ImportError:
|
from headphones import (
|
||||||
# Python 2.6.x fallback, from libs
|
crier,
|
||||||
from ordereddict import OrderedDict
|
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
|
||||||
|
|
||||||
|
|
||||||
def serve_template(templatename, **kwargs):
|
def serve_template(templatename, **kwargs):
|
||||||
@@ -326,9 +338,9 @@ class WebInterface(object):
|
|||||||
'$first': firstchar.lower(),
|
'$first': firstchar.lower(),
|
||||||
}
|
}
|
||||||
|
|
||||||
folder = helpers.pattern_substitute(folder_format.strip(), values, normalize=True)
|
folder = pattern_substitute(folder_format.strip(), values, normalize=True)
|
||||||
|
|
||||||
folder = helpers.replace_illegal_chars(folder, type="folder")
|
folder = replace_illegal_chars(folder, type="folder")
|
||||||
folder = folder.replace('./', '_/').replace('/.', '/_')
|
folder = folder.replace('./', '_/').replace('/.', '/_')
|
||||||
|
|
||||||
if folder.endswith('.'):
|
if folder.endswith('.'):
|
||||||
@@ -415,9 +427,9 @@ class WebInterface(object):
|
|||||||
myDB = db.DBConnection()
|
myDB = db.DBConnection()
|
||||||
for artist in args:
|
for artist in args:
|
||||||
myDB.action('DELETE FROM newartists WHERE ArtistName=?',
|
myDB.action('DELETE FROM newartists WHERE ArtistName=?',
|
||||||
[artist.decode(headphones.SYS_ENCODING, 'replace')])
|
[artist])
|
||||||
myDB.action('UPDATE have SET Matched="Ignored" WHERE ArtistName=?',
|
myDB.action('UPDATE have SET Matched="Ignored" WHERE ArtistName=?',
|
||||||
[artist.decode(headphones.SYS_ENCODING, 'replace')])
|
[artist])
|
||||||
logger.info("Artist %s removed from new artist list and set to ignored" % artist)
|
logger.info("Artist %s removed from new artist list and set to ignored" % artist)
|
||||||
raise cherrypy.HTTPRedirect("home")
|
raise cherrypy.HTTPRedirect("home")
|
||||||
|
|
||||||
@@ -440,40 +452,27 @@ class WebInterface(object):
|
|||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
@cherrypy.tools.json_out()
|
@cherrypy.tools.json_out()
|
||||||
def choose_specific_download(self, AlbumID):
|
def choose_specific_download(self, AlbumID):
|
||||||
results = searcher.searchforalbum(AlbumID, choose_specific_download=True)
|
results = searcher.searchforalbum(AlbumID, choose_specific_download=True) or []
|
||||||
|
return list(map(asdict, results))
|
||||||
data = []
|
|
||||||
|
|
||||||
for result in results:
|
|
||||||
result_dict = {
|
|
||||||
'title': result[0],
|
|
||||||
'size': result[1],
|
|
||||||
'url': result[2],
|
|
||||||
'provider': result[3],
|
|
||||||
'kind': result[4],
|
|
||||||
'matches': result[5]
|
|
||||||
}
|
|
||||||
data.append(result_dict)
|
|
||||||
return data
|
|
||||||
|
|
||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
@cherrypy.tools.json_out()
|
@cherrypy.tools.json_out()
|
||||||
def download_specific_release(self, AlbumID, title, size, url, provider, kind, **kwargs):
|
def download_specific_release(self, AlbumID, title, size, url, provider, kind, **kwargs):
|
||||||
# Handle situations where the torrent url contains arguments that are parsed
|
# Handle situations where the torrent url contains arguments that are parsed
|
||||||
if kwargs:
|
if kwargs:
|
||||||
url = urllib.parse.quote(url, safe=":?/=&") + '&' + urllib.parse.urlencode(kwargs)
|
url = parse.quote(url, safe=":?/=&") + '&' + parse.urlencode(kwargs)
|
||||||
try:
|
try:
|
||||||
result = [(title, int(size), url, provider, kind)]
|
result = [Result(title, int(size), url, provider, kind, True)]
|
||||||
except ValueError:
|
except ValueError:
|
||||||
result = [(title, float(size), url, provider, kind)]
|
result = [Result(title, float(size), url, provider, kind, True)]
|
||||||
|
|
||||||
logger.info("Making sure we can download the chosen result")
|
logger.info("Making sure we can download the chosen result")
|
||||||
(data, bestqual) = searcher.preprocess(result)
|
data, result = searcher.preprocess(result)
|
||||||
|
|
||||||
if data and bestqual:
|
if data and result:
|
||||||
myDB = db.DBConnection()
|
myDB = db.DBConnection()
|
||||||
album = myDB.action('SELECT * from albums WHERE AlbumID=?', [AlbumID]).fetchone()
|
album = myDB.action('SELECT * from albums WHERE AlbumID=?', [AlbumID]).fetchone()
|
||||||
searcher.send_to_downloader(data, bestqual, album)
|
searcher.send_to_downloader(data, result, album)
|
||||||
return {'result': 'success'}
|
return {'result': 'success'}
|
||||||
else:
|
else:
|
||||||
return {'result': 'failure'}
|
return {'result': 'failure'}
|
||||||
@@ -586,7 +585,7 @@ class WebInterface(object):
|
|||||||
for albums in have_albums:
|
for albums in have_albums:
|
||||||
# Have to skip over manually matched tracks
|
# Have to skip over manually matched tracks
|
||||||
if albums['ArtistName'] and albums['AlbumTitle'] and albums['TrackTitle']:
|
if albums['ArtistName'] and albums['AlbumTitle'] and albums['TrackTitle']:
|
||||||
original_clean = helpers.clean_name(
|
original_clean = clean_name(
|
||||||
albums['ArtistName'] + " " + albums['AlbumTitle'] + " " + albums['TrackTitle'])
|
albums['ArtistName'] + " " + albums['AlbumTitle'] + " " + albums['TrackTitle'])
|
||||||
# else:
|
# else:
|
||||||
# original_clean = None
|
# original_clean = None
|
||||||
@@ -633,8 +632,8 @@ class WebInterface(object):
|
|||||||
(artist, album))
|
(artist, album))
|
||||||
|
|
||||||
elif action == "matchArtist":
|
elif action == "matchArtist":
|
||||||
existing_artist_clean = helpers.clean_name(existing_artist).lower()
|
existing_artist_clean = clean_name(existing_artist).lower()
|
||||||
new_artist_clean = helpers.clean_name(new_artist).lower()
|
new_artist_clean = clean_name(new_artist).lower()
|
||||||
if new_artist_clean != existing_artist_clean:
|
if new_artist_clean != existing_artist_clean:
|
||||||
have_tracks = myDB.action(
|
have_tracks = myDB.action(
|
||||||
'SELECT Matched, CleanName, Location, BitRate, Format FROM have WHERE ArtistName=?',
|
'SELECT Matched, CleanName, Location, BitRate, Format FROM have WHERE ArtistName=?',
|
||||||
@@ -678,10 +677,10 @@ class WebInterface(object):
|
|||||||
"Artist %s already named appropriately; nothing to modify" % existing_artist)
|
"Artist %s already named appropriately; nothing to modify" % existing_artist)
|
||||||
|
|
||||||
elif action == "matchAlbum":
|
elif action == "matchAlbum":
|
||||||
existing_artist_clean = helpers.clean_name(existing_artist).lower()
|
existing_artist_clean = clean_name(existing_artist).lower()
|
||||||
new_artist_clean = helpers.clean_name(new_artist).lower()
|
new_artist_clean = clean_name(new_artist).lower()
|
||||||
existing_album_clean = helpers.clean_name(existing_album).lower()
|
existing_album_clean = clean_name(existing_album).lower()
|
||||||
new_album_clean = helpers.clean_name(new_album).lower()
|
new_album_clean = clean_name(new_album).lower()
|
||||||
existing_clean_string = existing_artist_clean + " " + existing_album_clean
|
existing_clean_string = existing_artist_clean + " " + existing_album_clean
|
||||||
new_clean_string = new_artist_clean + " " + new_album_clean
|
new_clean_string = new_artist_clean + " " + new_album_clean
|
||||||
if existing_clean_string != new_clean_string:
|
if existing_clean_string != new_clean_string:
|
||||||
@@ -737,7 +736,7 @@ class WebInterface(object):
|
|||||||
'SELECT ArtistName, AlbumTitle, TrackTitle, CleanName, Matched from have')
|
'SELECT ArtistName, AlbumTitle, TrackTitle, CleanName, Matched from have')
|
||||||
for albums in manualalbums:
|
for albums in manualalbums:
|
||||||
if albums['ArtistName'] and albums['AlbumTitle'] and albums['TrackTitle']:
|
if albums['ArtistName'] and albums['AlbumTitle'] and albums['TrackTitle']:
|
||||||
original_clean = helpers.clean_name(
|
original_clean = clean_name(
|
||||||
albums['ArtistName'] + " " + albums['AlbumTitle'] + " " + albums['TrackTitle'])
|
albums['ArtistName'] + " " + albums['AlbumTitle'] + " " + albums['TrackTitle'])
|
||||||
if albums['Matched'] == "Ignored" or albums['Matched'] == "Manual" or albums[
|
if albums['Matched'] == "Ignored" or albums['Matched'] == "Manual" or albums[
|
||||||
'CleanName'] != original_clean:
|
'CleanName'] != original_clean:
|
||||||
@@ -778,7 +777,7 @@ class WebInterface(object):
|
|||||||
[artist])
|
[artist])
|
||||||
update_count = 0
|
update_count = 0
|
||||||
for tracks in update_clean:
|
for tracks in update_clean:
|
||||||
original_clean = helpers.clean_name(
|
original_clean = clean_name(
|
||||||
tracks['ArtistName'] + " " + tracks['AlbumTitle'] + " " + tracks[
|
tracks['ArtistName'] + " " + tracks['AlbumTitle'] + " " + tracks[
|
||||||
'TrackTitle']).lower()
|
'TrackTitle']).lower()
|
||||||
album = tracks['AlbumTitle']
|
album = tracks['AlbumTitle']
|
||||||
@@ -810,7 +809,7 @@ class WebInterface(object):
|
|||||||
(artist, album))
|
(artist, album))
|
||||||
update_count = 0
|
update_count = 0
|
||||||
for tracks in update_clean:
|
for tracks in update_clean:
|
||||||
original_clean = helpers.clean_name(
|
original_clean = clean_name(
|
||||||
tracks['ArtistName'] + " " + tracks['AlbumTitle'] + " " + tracks[
|
tracks['ArtistName'] + " " + tracks['AlbumTitle'] + " " + tracks[
|
||||||
'TrackTitle']).lower()
|
'TrackTitle']).lower()
|
||||||
track_title = tracks['TrackTitle']
|
track_title = tracks['TrackTitle']
|
||||||
@@ -1018,9 +1017,7 @@ class WebInterface(object):
|
|||||||
totalcount = myDB.select('SELECT COUNT(*) from artists')[0][0]
|
totalcount = myDB.select('SELECT COUNT(*) from artists')[0][0]
|
||||||
|
|
||||||
if sortbyhavepercent:
|
if sortbyhavepercent:
|
||||||
filtered.sort(key=lambda x: (
|
filtered.sort(key=have_pct_have_total, reverse=sSortDir_0 == "asc")
|
||||||
float(x['HaveTracks']) / x['TotalTracks'] if x['TotalTracks'] > 0 else 0.0,
|
|
||||||
x['HaveTracks'] if x['HaveTracks'] else 0.0), reverse=sSortDir_0 == "asc")
|
|
||||||
|
|
||||||
# can't figure out how to change the datatables default sorting order when its using an ajax datasource so ill
|
# can't figure out how to change the datatables default sorting order when its using an ajax datasource so ill
|
||||||
# just reverse it here and the first click on the "Latest Album" header will sort by descending release date
|
# just reverse it here and the first click on the "Latest Album" header will sort by descending release date
|
||||||
@@ -1186,6 +1183,7 @@ class WebInterface(object):
|
|||||||
"deluge_password": headphones.CONFIG.DELUGE_PASSWORD,
|
"deluge_password": headphones.CONFIG.DELUGE_PASSWORD,
|
||||||
"deluge_label": headphones.CONFIG.DELUGE_LABEL,
|
"deluge_label": headphones.CONFIG.DELUGE_LABEL,
|
||||||
"deluge_done_directory": headphones.CONFIG.DELUGE_DONE_DIRECTORY,
|
"deluge_done_directory": headphones.CONFIG.DELUGE_DONE_DIRECTORY,
|
||||||
|
"deluge_download_directory": headphones.CONFIG.DELUGE_DOWNLOAD_DIRECTORY,
|
||||||
"deluge_paused": checked(headphones.CONFIG.DELUGE_PAUSED),
|
"deluge_paused": checked(headphones.CONFIG.DELUGE_PAUSED),
|
||||||
"utorrent_host": headphones.CONFIG.UTORRENT_HOST,
|
"utorrent_host": headphones.CONFIG.UTORRENT_HOST,
|
||||||
"utorrent_username": headphones.CONFIG.UTORRENT_USERNAME,
|
"utorrent_username": headphones.CONFIG.UTORRENT_USERNAME,
|
||||||
@@ -1200,6 +1198,8 @@ class WebInterface(object):
|
|||||||
"torrent_downloader_deluge": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 3),
|
"torrent_downloader_deluge": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 3),
|
||||||
"torrent_downloader_qbittorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 4),
|
"torrent_downloader_qbittorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 4),
|
||||||
"download_dir": headphones.CONFIG.DOWNLOAD_DIR,
|
"download_dir": headphones.CONFIG.DOWNLOAD_DIR,
|
||||||
|
"soulseek_download_dir": headphones.CONFIG.SOULSEEK_DOWNLOAD_DIR,
|
||||||
|
"soulseek_incomplete_download_dir": headphones.CONFIG.SOULSEEK_INCOMPLETE_DOWNLOAD_DIR,
|
||||||
"use_blackhole": checked(headphones.CONFIG.BLACKHOLE),
|
"use_blackhole": checked(headphones.CONFIG.BLACKHOLE),
|
||||||
"blackhole_dir": headphones.CONFIG.BLACKHOLE_DIR,
|
"blackhole_dir": headphones.CONFIG.BLACKHOLE_DIR,
|
||||||
"usenet_retention": headphones.CONFIG.USENET_RETENTION,
|
"usenet_retention": headphones.CONFIG.USENET_RETENTION,
|
||||||
@@ -1231,13 +1231,6 @@ class WebInterface(object):
|
|||||||
"use_piratebay": checked(headphones.CONFIG.PIRATEBAY),
|
"use_piratebay": checked(headphones.CONFIG.PIRATEBAY),
|
||||||
"piratebay_proxy_url": headphones.CONFIG.PIRATEBAY_PROXY_URL,
|
"piratebay_proxy_url": headphones.CONFIG.PIRATEBAY_PROXY_URL,
|
||||||
"piratebay_ratio": headphones.CONFIG.PIRATEBAY_RATIO,
|
"piratebay_ratio": headphones.CONFIG.PIRATEBAY_RATIO,
|
||||||
"use_oldpiratebay": checked(headphones.CONFIG.OLDPIRATEBAY),
|
|
||||||
"oldpiratebay_url": headphones.CONFIG.OLDPIRATEBAY_URL,
|
|
||||||
"oldpiratebay_ratio": headphones.CONFIG.OLDPIRATEBAY_RATIO,
|
|
||||||
"use_waffles": checked(headphones.CONFIG.WAFFLES),
|
|
||||||
"waffles_uid": headphones.CONFIG.WAFFLES_UID,
|
|
||||||
"waffles_passkey": headphones.CONFIG.WAFFLES_PASSKEY,
|
|
||||||
"waffles_ratio": headphones.CONFIG.WAFFLES_RATIO,
|
|
||||||
"use_rutracker": checked(headphones.CONFIG.RUTRACKER),
|
"use_rutracker": checked(headphones.CONFIG.RUTRACKER),
|
||||||
"rutracker_user": headphones.CONFIG.RUTRACKER_USER,
|
"rutracker_user": headphones.CONFIG.RUTRACKER_USER,
|
||||||
"rutracker_password": headphones.CONFIG.RUTRACKER_PASSWORD,
|
"rutracker_password": headphones.CONFIG.RUTRACKER_PASSWORD,
|
||||||
@@ -1249,6 +1242,7 @@ class WebInterface(object):
|
|||||||
"orpheus_ratio": headphones.CONFIG.ORPHEUS_RATIO,
|
"orpheus_ratio": headphones.CONFIG.ORPHEUS_RATIO,
|
||||||
"orpheus_url": headphones.CONFIG.ORPHEUS_URL,
|
"orpheus_url": headphones.CONFIG.ORPHEUS_URL,
|
||||||
"use_redacted": checked(headphones.CONFIG.REDACTED),
|
"use_redacted": checked(headphones.CONFIG.REDACTED),
|
||||||
|
"redacted_apikey": headphones.CONFIG.REDACTED_APIKEY,
|
||||||
"redacted_username": headphones.CONFIG.REDACTED_USERNAME,
|
"redacted_username": headphones.CONFIG.REDACTED_USERNAME,
|
||||||
"redacted_password": headphones.CONFIG.REDACTED_PASSWORD,
|
"redacted_password": headphones.CONFIG.REDACTED_PASSWORD,
|
||||||
"redacted_ratio": headphones.CONFIG.REDACTED_RATIO,
|
"redacted_ratio": headphones.CONFIG.REDACTED_RATIO,
|
||||||
@@ -1271,6 +1265,7 @@ class WebInterface(object):
|
|||||||
"cue_split_shntool_path": headphones.CONFIG.CUE_SPLIT_SHNTOOL_PATH,
|
"cue_split_shntool_path": headphones.CONFIG.CUE_SPLIT_SHNTOOL_PATH,
|
||||||
"move_files": checked(headphones.CONFIG.MOVE_FILES),
|
"move_files": checked(headphones.CONFIG.MOVE_FILES),
|
||||||
"rename_files": checked(headphones.CONFIG.RENAME_FILES),
|
"rename_files": checked(headphones.CONFIG.RENAME_FILES),
|
||||||
|
"rename_single_disc_ignore": checked(headphones.CONFIG.RENAME_SINGLE_DISC_IGNORE),
|
||||||
"correct_metadata": checked(headphones.CONFIG.CORRECT_METADATA),
|
"correct_metadata": checked(headphones.CONFIG.CORRECT_METADATA),
|
||||||
"cleanup_files": checked(headphones.CONFIG.CLEANUP_FILES),
|
"cleanup_files": checked(headphones.CONFIG.CLEANUP_FILES),
|
||||||
"keep_nfo": checked(headphones.CONFIG.KEEP_NFO),
|
"keep_nfo": checked(headphones.CONFIG.KEEP_NFO),
|
||||||
@@ -1298,6 +1293,7 @@ class WebInterface(object):
|
|||||||
"prefer_torrents_0": radio(headphones.CONFIG.PREFER_TORRENTS, 0),
|
"prefer_torrents_0": radio(headphones.CONFIG.PREFER_TORRENTS, 0),
|
||||||
"prefer_torrents_1": radio(headphones.CONFIG.PREFER_TORRENTS, 1),
|
"prefer_torrents_1": radio(headphones.CONFIG.PREFER_TORRENTS, 1),
|
||||||
"prefer_torrents_2": radio(headphones.CONFIG.PREFER_TORRENTS, 2),
|
"prefer_torrents_2": radio(headphones.CONFIG.PREFER_TORRENTS, 2),
|
||||||
|
"prefer_torrents_3": radio(headphones.CONFIG.PREFER_TORRENTS, 3),
|
||||||
"magnet_links_0": radio(headphones.CONFIG.MAGNET_LINKS, 0),
|
"magnet_links_0": radio(headphones.CONFIG.MAGNET_LINKS, 0),
|
||||||
"magnet_links_1": radio(headphones.CONFIG.MAGNET_LINKS, 1),
|
"magnet_links_1": radio(headphones.CONFIG.MAGNET_LINKS, 1),
|
||||||
"magnet_links_2": radio(headphones.CONFIG.MAGNET_LINKS, 2),
|
"magnet_links_2": radio(headphones.CONFIG.MAGNET_LINKS, 2),
|
||||||
@@ -1320,10 +1316,6 @@ class WebInterface(object):
|
|||||||
"encoder_multicore": checked(headphones.CONFIG.ENCODER_MULTICORE),
|
"encoder_multicore": checked(headphones.CONFIG.ENCODER_MULTICORE),
|
||||||
"encoder_multicore_count": int(headphones.CONFIG.ENCODER_MULTICORE_COUNT),
|
"encoder_multicore_count": int(headphones.CONFIG.ENCODER_MULTICORE_COUNT),
|
||||||
"delete_lossless_files": checked(headphones.CONFIG.DELETE_LOSSLESS_FILES),
|
"delete_lossless_files": checked(headphones.CONFIG.DELETE_LOSSLESS_FILES),
|
||||||
"growl_enabled": checked(headphones.CONFIG.GROWL_ENABLED),
|
|
||||||
"growl_onsnatch": checked(headphones.CONFIG.GROWL_ONSNATCH),
|
|
||||||
"growl_host": headphones.CONFIG.GROWL_HOST,
|
|
||||||
"growl_password": headphones.CONFIG.GROWL_PASSWORD,
|
|
||||||
"prowl_enabled": checked(headphones.CONFIG.PROWL_ENABLED),
|
"prowl_enabled": checked(headphones.CONFIG.PROWL_ENABLED),
|
||||||
"prowl_onsnatch": checked(headphones.CONFIG.PROWL_ONSNATCH),
|
"prowl_onsnatch": checked(headphones.CONFIG.PROWL_ONSNATCH),
|
||||||
"prowl_keys": headphones.CONFIG.PROWL_KEYS,
|
"prowl_keys": headphones.CONFIG.PROWL_KEYS,
|
||||||
@@ -1373,10 +1365,6 @@ class WebInterface(object):
|
|||||||
"twitter_onsnatch": checked(headphones.CONFIG.TWITTER_ONSNATCH),
|
"twitter_onsnatch": checked(headphones.CONFIG.TWITTER_ONSNATCH),
|
||||||
"osx_notify_enabled": checked(headphones.CONFIG.OSX_NOTIFY_ENABLED),
|
"osx_notify_enabled": checked(headphones.CONFIG.OSX_NOTIFY_ENABLED),
|
||||||
"osx_notify_onsnatch": checked(headphones.CONFIG.OSX_NOTIFY_ONSNATCH),
|
"osx_notify_onsnatch": checked(headphones.CONFIG.OSX_NOTIFY_ONSNATCH),
|
||||||
"osx_notify_app": headphones.CONFIG.OSX_NOTIFY_APP,
|
|
||||||
"boxcar_enabled": checked(headphones.CONFIG.BOXCAR_ENABLED),
|
|
||||||
"boxcar_onsnatch": checked(headphones.CONFIG.BOXCAR_ONSNATCH),
|
|
||||||
"boxcar_token": headphones.CONFIG.BOXCAR_TOKEN,
|
|
||||||
"mirrorlist": headphones.MIRRORLIST,
|
"mirrorlist": headphones.MIRRORLIST,
|
||||||
"mirror": headphones.CONFIG.MIRROR,
|
"mirror": headphones.CONFIG.MIRROR,
|
||||||
"customhost": headphones.CONFIG.CUSTOMHOST,
|
"customhost": headphones.CONFIG.CUSTOMHOST,
|
||||||
@@ -1387,6 +1375,7 @@ class WebInterface(object):
|
|||||||
"custompass": headphones.CONFIG.CUSTOMPASS,
|
"custompass": headphones.CONFIG.CUSTOMPASS,
|
||||||
"hpuser": headphones.CONFIG.HPUSER,
|
"hpuser": headphones.CONFIG.HPUSER,
|
||||||
"hppass": headphones.CONFIG.HPPASS,
|
"hppass": headphones.CONFIG.HPPASS,
|
||||||
|
"lastfm_apikey": headphones.CONFIG.LASTFM_APIKEY,
|
||||||
"songkick_enabled": checked(headphones.CONFIG.SONGKICK_ENABLED),
|
"songkick_enabled": checked(headphones.CONFIG.SONGKICK_ENABLED),
|
||||||
"songkick_apikey": headphones.CONFIG.SONGKICK_APIKEY,
|
"songkick_apikey": headphones.CONFIG.SONGKICK_APIKEY,
|
||||||
"songkick_location": headphones.CONFIG.SONGKICK_LOCATION,
|
"songkick_location": headphones.CONFIG.SONGKICK_LOCATION,
|
||||||
@@ -1414,7 +1403,12 @@ class WebInterface(object):
|
|||||||
"join_enabled": checked(headphones.CONFIG.JOIN_ENABLED),
|
"join_enabled": checked(headphones.CONFIG.JOIN_ENABLED),
|
||||||
"join_onsnatch": checked(headphones.CONFIG.JOIN_ONSNATCH),
|
"join_onsnatch": checked(headphones.CONFIG.JOIN_ONSNATCH),
|
||||||
"join_apikey": headphones.CONFIG.JOIN_APIKEY,
|
"join_apikey": headphones.CONFIG.JOIN_APIKEY,
|
||||||
"join_deviceid": headphones.CONFIG.JOIN_DEVICEID
|
"join_deviceid": headphones.CONFIG.JOIN_DEVICEID,
|
||||||
|
"use_bandcamp": checked(headphones.CONFIG.BANDCAMP),
|
||||||
|
"bandcamp_dir": headphones.CONFIG.BANDCAMP_DIR,
|
||||||
|
'soulseek_api_url': headphones.CONFIG.SOULSEEK_API_URL,
|
||||||
|
'soulseek_api_key': headphones.CONFIG.SOULSEEK_API_KEY,
|
||||||
|
'use_soulseek': checked(headphones.CONFIG.SOULSEEK)
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v in config.items():
|
for k, v in config.items():
|
||||||
@@ -1459,31 +1453,30 @@ class WebInterface(object):
|
|||||||
checked_configs = [
|
checked_configs = [
|
||||||
"launch_browser", "enable_https", "api_enabled", "use_blackhole", "headphones_indexer",
|
"launch_browser", "enable_https", "api_enabled", "use_blackhole", "headphones_indexer",
|
||||||
"use_newznab", "newznab_enabled", "use_torznab", "torznab_enabled",
|
"use_newznab", "newznab_enabled", "use_torznab", "torznab_enabled",
|
||||||
"use_nzbsorg", "use_omgwtfnzbs", "use_piratebay", "use_oldpiratebay",
|
"use_nzbsorg", "use_omgwtfnzbs", "use_piratebay", "use_rutracker",
|
||||||
"use_waffles", "use_rutracker",
|
|
||||||
"use_orpheus", "use_redacted", "redacted_use_fltoken", "preferred_bitrate_allow_lossless",
|
"use_orpheus", "use_redacted", "redacted_use_fltoken", "preferred_bitrate_allow_lossless",
|
||||||
"detect_bitrate", "ignore_clean_releases", "freeze_db", "cue_split", "move_files",
|
"detect_bitrate", "ignore_clean_releases", "freeze_db", "cue_split", "move_files",
|
||||||
"rename_files", "correct_metadata", "cleanup_files", "keep_nfo", "add_album_art",
|
"rename_files", "rename_single_disc_ignore", "correct_metadata", "cleanup_files",
|
||||||
"embed_album_art", "embed_lyrics",
|
"keep_nfo", "add_album_art", "embed_album_art", "embed_lyrics",
|
||||||
"replace_existing_folders", "keep_original_folder", "file_underscores",
|
"replace_existing_folders", "keep_original_folder", "file_underscores",
|
||||||
"include_extras", "official_releases_only",
|
"include_extras", "official_releases_only",
|
||||||
"wait_until_release_date", "autowant_upcoming", "autowant_all",
|
"wait_until_release_date", "autowant_upcoming", "autowant_all",
|
||||||
"autowant_manually_added", "do_not_process_unmatched", "keep_torrent_files",
|
"autowant_manually_added", "do_not_process_unmatched", "keep_torrent_files",
|
||||||
"music_encoder", "mb_ignore_age_missing",
|
"music_encoder", "mb_ignore_age_missing",
|
||||||
"encoderlossless", "encoder_multicore", "delete_lossless_files", "growl_enabled",
|
"encoderlossless", "encoder_multicore", "delete_lossless_files",
|
||||||
"growl_onsnatch", "prowl_enabled",
|
"prowl_enabled", "prowl_onsnatch",
|
||||||
"prowl_onsnatch", "xbmc_enabled", "xbmc_update", "xbmc_notify", "lms_enabled",
|
"xbmc_enabled", "xbmc_update", "xbmc_notify", "lms_enabled",
|
||||||
"plex_enabled", "plex_update", "plex_notify",
|
"plex_enabled", "plex_update", "plex_notify",
|
||||||
"nma_enabled", "nma_onsnatch", "pushalot_enabled", "pushalot_onsnatch",
|
"nma_enabled", "nma_onsnatch", "pushalot_enabled", "pushalot_onsnatch",
|
||||||
"synoindex_enabled", "pushover_enabled",
|
"synoindex_enabled", "pushover_enabled",
|
||||||
"pushover_onsnatch", "pushbullet_enabled", "pushbullet_onsnatch", "subsonic_enabled",
|
"pushover_onsnatch", "pushbullet_enabled", "pushbullet_onsnatch", "subsonic_enabled",
|
||||||
"twitter_enabled", "twitter_onsnatch",
|
"twitter_enabled", "twitter_onsnatch",
|
||||||
"telegram_enabled", "telegram_onsnatch",
|
"telegram_enabled", "telegram_onsnatch",
|
||||||
"osx_notify_enabled", "osx_notify_onsnatch", "boxcar_enabled", "boxcar_onsnatch",
|
"osx_notify_enabled", "osx_notify_onsnatch",
|
||||||
"songkick_enabled", "songkick_filter_enabled",
|
"songkick_enabled", "songkick_filter_enabled",
|
||||||
"mpc_enabled", "email_enabled", "email_ssl", "email_tls", "email_onsnatch",
|
"mpc_enabled", "email_enabled", "email_ssl", "email_tls", "email_onsnatch",
|
||||||
"customauth", "idtag", "deluge_paused",
|
"customauth", "idtag", "deluge_paused",
|
||||||
"join_enabled", "join_onsnatch"
|
"join_enabled", "join_onsnatch", "use_bandcamp", "use_soulseek"
|
||||||
]
|
]
|
||||||
for checked_config in checked_configs:
|
for checked_config in checked_configs:
|
||||||
if checked_config not in kwargs:
|
if checked_config not in kwargs:
|
||||||
@@ -1717,20 +1710,6 @@ class WebInterface(object):
|
|||||||
else:
|
else:
|
||||||
return "Error sending tweet"
|
return "Error sending tweet"
|
||||||
|
|
||||||
@cherrypy.expose
|
|
||||||
def osxnotifyregister(self, app):
|
|
||||||
cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store"
|
|
||||||
from osxnotify import registerapp as osxnotify
|
|
||||||
result, msg = osxnotify.registerapp(app)
|
|
||||||
if result:
|
|
||||||
osx_notify = notifiers.OSX_NOTIFY()
|
|
||||||
osx_notify.notify('Registered', result, 'Success :-)')
|
|
||||||
logger.info(
|
|
||||||
'Registered %s, to re-register a different app, delete this app first' % result)
|
|
||||||
else:
|
|
||||||
logger.warn(msg)
|
|
||||||
return msg
|
|
||||||
|
|
||||||
@cherrypy.expose
|
@cherrypy.expose
|
||||||
def testPushover(self):
|
def testPushover(self):
|
||||||
logger.info("Sending Pushover notification")
|
logger.info("Sending Pushover notification")
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
version_info = (3, 0, 1)
|
from pkg_resources import get_distribution, DistributionNotFound
|
||||||
version = '3.0.1'
|
|
||||||
release = '3.0.1'
|
|
||||||
|
|
||||||
__version__ = release # PEP 396
|
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
|
||||||
|
|||||||
+42
-21
@@ -1,25 +1,33 @@
|
|||||||
__all__ = ('EVENT_SCHEDULER_START', 'EVENT_SCHEDULER_SHUTDOWN', 'EVENT_EXECUTOR_ADDED', 'EVENT_EXECUTOR_REMOVED',
|
__all__ = ('EVENT_SCHEDULER_STARTED', 'EVENT_SCHEDULER_SHUTDOWN', 'EVENT_SCHEDULER_PAUSED',
|
||||||
'EVENT_JOBSTORE_ADDED', 'EVENT_JOBSTORE_REMOVED', 'EVENT_ALL_JOBS_REMOVED', 'EVENT_JOB_ADDED',
|
'EVENT_SCHEDULER_RESUMED', 'EVENT_EXECUTOR_ADDED', 'EVENT_EXECUTOR_REMOVED',
|
||||||
'EVENT_JOB_REMOVED', 'EVENT_JOB_MODIFIED', 'EVENT_JOB_EXECUTED', 'EVENT_JOB_ERROR', 'EVENT_JOB_MISSED',
|
'EVENT_JOBSTORE_ADDED', 'EVENT_JOBSTORE_REMOVED', 'EVENT_ALL_JOBS_REMOVED',
|
||||||
'SchedulerEvent', 'JobEvent', 'JobExecutionEvent')
|
'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')
|
||||||
|
|
||||||
|
|
||||||
EVENT_SCHEDULER_START = 1
|
EVENT_SCHEDULER_STARTED = EVENT_SCHEDULER_START = 2 ** 0
|
||||||
EVENT_SCHEDULER_SHUTDOWN = 2
|
EVENT_SCHEDULER_SHUTDOWN = 2 ** 1
|
||||||
EVENT_EXECUTOR_ADDED = 4
|
EVENT_SCHEDULER_PAUSED = 2 ** 2
|
||||||
EVENT_EXECUTOR_REMOVED = 8
|
EVENT_SCHEDULER_RESUMED = 2 ** 3
|
||||||
EVENT_JOBSTORE_ADDED = 16
|
EVENT_EXECUTOR_ADDED = 2 ** 4
|
||||||
EVENT_JOBSTORE_REMOVED = 32
|
EVENT_EXECUTOR_REMOVED = 2 ** 5
|
||||||
EVENT_ALL_JOBS_REMOVED = 64
|
EVENT_JOBSTORE_ADDED = 2 ** 6
|
||||||
EVENT_JOB_ADDED = 128
|
EVENT_JOBSTORE_REMOVED = 2 ** 7
|
||||||
EVENT_JOB_REMOVED = 256
|
EVENT_ALL_JOBS_REMOVED = 2 ** 8
|
||||||
EVENT_JOB_MODIFIED = 512
|
EVENT_JOB_ADDED = 2 ** 9
|
||||||
EVENT_JOB_EXECUTED = 1024
|
EVENT_JOB_REMOVED = 2 ** 10
|
||||||
EVENT_JOB_ERROR = 2048
|
EVENT_JOB_MODIFIED = 2 ** 11
|
||||||
EVENT_JOB_MISSED = 4096
|
EVENT_JOB_EXECUTED = 2 ** 12
|
||||||
EVENT_ALL = (EVENT_SCHEDULER_START | EVENT_SCHEDULER_SHUTDOWN | EVENT_JOBSTORE_ADDED | EVENT_JOBSTORE_REMOVED |
|
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_JOB_ADDED | EVENT_JOB_REMOVED | EVENT_JOB_MODIFIED | EVENT_JOB_EXECUTED |
|
EVENT_JOB_ADDED | EVENT_JOB_REMOVED | EVENT_JOB_MODIFIED | EVENT_JOB_EXECUTED |
|
||||||
EVENT_JOB_ERROR | EVENT_JOB_MISSED)
|
EVENT_JOB_ERROR | EVENT_JOB_MISSED | EVENT_JOB_SUBMITTED | EVENT_JOB_MAX_INSTANCES)
|
||||||
|
|
||||||
|
|
||||||
class SchedulerEvent(object):
|
class SchedulerEvent(object):
|
||||||
@@ -55,9 +63,21 @@ class JobEvent(SchedulerEvent):
|
|||||||
self.jobstore = jobstore
|
self.jobstore = jobstore
|
||||||
|
|
||||||
|
|
||||||
|
class JobSubmissionEvent(JobEvent):
|
||||||
|
"""
|
||||||
|
An event that concerns the submission of a job to its executor.
|
||||||
|
|
||||||
|
:ivar scheduled_run_times: a list of datetimes when the job was intended to run
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, code, job_id, jobstore, scheduled_run_times):
|
||||||
|
super(JobSubmissionEvent, self).__init__(code, job_id, jobstore)
|
||||||
|
self.scheduled_run_times = scheduled_run_times
|
||||||
|
|
||||||
|
|
||||||
class JobExecutionEvent(JobEvent):
|
class JobExecutionEvent(JobEvent):
|
||||||
"""
|
"""
|
||||||
An event that concerns the execution of individual jobs.
|
An event that concerns the running of a job within its executor.
|
||||||
|
|
||||||
:ivar scheduled_run_time: the time when the job was scheduled to be run
|
:ivar scheduled_run_time: the time when the job was scheduled to be run
|
||||||
:ivar retval: the return value of the successfully executed job
|
:ivar retval: the return value of the successfully executed job
|
||||||
@@ -65,7 +85,8 @@ class JobExecutionEvent(JobEvent):
|
|||||||
:ivar traceback: a formatted traceback for the exception
|
:ivar traceback: a formatted traceback for the exception
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, code, job_id, jobstore, scheduled_run_time, retval=None, exception=None, 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)
|
super(JobExecutionEvent, self).__init__(code, job_id, jobstore)
|
||||||
self.scheduled_run_time = scheduled_run_time
|
self.scheduled_run_time = scheduled_run_time
|
||||||
self.retval = retval
|
self.retval = retval
|
||||||
|
|||||||
@@ -1,28 +1,52 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from apscheduler.executors.base import BaseExecutor, run_job
|
from apscheduler.executors.base import BaseExecutor, run_job
|
||||||
|
from apscheduler.executors.base_py3 import run_coroutine_job
|
||||||
|
from apscheduler.util import iscoroutinefunction_partial
|
||||||
|
|
||||||
|
|
||||||
class AsyncIOExecutor(BaseExecutor):
|
class AsyncIOExecutor(BaseExecutor):
|
||||||
"""
|
"""
|
||||||
Runs jobs in the default executor of the event loop.
|
Runs jobs in the default executor of the event loop.
|
||||||
|
|
||||||
|
If the job function is a native coroutine function, it is scheduled to be run directly in the
|
||||||
|
event loop as soon as possible. All other functions are run in the event loop's default
|
||||||
|
executor which is usually a thread pool.
|
||||||
|
|
||||||
Plugin alias: ``asyncio``
|
Plugin alias: ``asyncio``
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def start(self, scheduler, alias):
|
def start(self, scheduler, alias):
|
||||||
super(AsyncIOExecutor, self).start(scheduler, alias)
|
super(AsyncIOExecutor, self).start(scheduler, alias)
|
||||||
self._eventloop = scheduler._eventloop
|
self._eventloop = scheduler._eventloop
|
||||||
|
self._pending_futures = set()
|
||||||
|
|
||||||
|
def shutdown(self, wait=True):
|
||||||
|
# There is no way to honor wait=True without converting this method into a coroutine method
|
||||||
|
for f in self._pending_futures:
|
||||||
|
if not f.done():
|
||||||
|
f.cancel()
|
||||||
|
|
||||||
|
self._pending_futures.clear()
|
||||||
|
|
||||||
def _do_submit_job(self, job, run_times):
|
def _do_submit_job(self, job, run_times):
|
||||||
def callback(f):
|
def callback(f):
|
||||||
|
self._pending_futures.discard(f)
|
||||||
try:
|
try:
|
||||||
events = f.result()
|
events = f.result()
|
||||||
except:
|
except BaseException:
|
||||||
self._run_job_error(job.id, *sys.exc_info()[1:])
|
self._run_job_error(job.id, *sys.exc_info()[1:])
|
||||||
else:
|
else:
|
||||||
self._run_job_success(job.id, events)
|
self._run_job_success(job.id, events)
|
||||||
|
|
||||||
f = self._eventloop.run_in_executor(None, run_job, job, job._jobstore_alias, run_times, self._logger.name)
|
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.add_done_callback(callback)
|
f.add_done_callback(callback)
|
||||||
|
self._pending_futures.add(f)
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ import sys
|
|||||||
from pytz import utc
|
from pytz import utc
|
||||||
import six
|
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):
|
class MaxInstancesReachedError(Exception):
|
||||||
def __init__(self, job):
|
def __init__(self, job):
|
||||||
super(MaxInstancesReachedError, self).__init__(
|
super(MaxInstancesReachedError, self).__init__(
|
||||||
'Job "%s" has already reached its maximum number of instances (%d)' % (job.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)):
|
class BaseExecutor(six.with_metaclass(ABCMeta, object)):
|
||||||
@@ -30,13 +32,14 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
|
|||||||
|
|
||||||
def start(self, scheduler, alias):
|
def start(self, scheduler, alias):
|
||||||
"""
|
"""
|
||||||
Called by the scheduler when the scheduler is being started or when the executor is being added to an already
|
Called by the scheduler when the scheduler is being started or when the executor is being
|
||||||
running scheduler.
|
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
|
:param str|unicode alias: alias of this executor as it was assigned to the scheduler
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
self._scheduler = scheduler
|
self._scheduler = scheduler
|
||||||
self._lock = scheduler._create_lock()
|
self._lock = scheduler._create_lock()
|
||||||
self._logger = logging.getLogger('apscheduler.executors.%s' % alias)
|
self._logger = logging.getLogger('apscheduler.executors.%s' % alias)
|
||||||
@@ -45,7 +48,8 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
|
|||||||
"""
|
"""
|
||||||
Shuts down this executor.
|
Shuts down this executor.
|
||||||
|
|
||||||
:param bool wait: ``True`` to wait until all submitted jobs have been executed
|
:param bool wait: ``True`` to wait until all submitted jobs
|
||||||
|
have been executed
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def submit_job(self, job, run_times):
|
def submit_job(self, job, run_times):
|
||||||
@@ -53,10 +57,12 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
|
|||||||
Submits job for execution.
|
Submits job for execution.
|
||||||
|
|
||||||
:param Job job: job to execute
|
:param Job job: job to execute
|
||||||
:param list[datetime] run_times: list of datetimes specifying when the job should have been run
|
:param list[datetime] run_times: list of datetimes specifying
|
||||||
:raises MaxInstancesReachedError: if the maximum number of allowed instances for this job has been reached
|
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'
|
assert self._lock is not None, 'This executor has not been started yet'
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if self._instances[job.id] >= job.max_instances:
|
if self._instances[job.id] >= job.max_instances:
|
||||||
@@ -70,50 +76,71 @@ class BaseExecutor(six.with_metaclass(ABCMeta, object)):
|
|||||||
"""Performs the actual task of scheduling `run_job` to be called."""
|
"""Performs the actual task of scheduling `run_job` to be called."""
|
||||||
|
|
||||||
def _run_job_success(self, job_id, events):
|
def _run_job_success(self, job_id, events):
|
||||||
"""Called by the executor with the list of generated events when `run_job` has been successfully called."""
|
"""
|
||||||
|
Called by the executor with the list of generated events when :func:`run_job` has been
|
||||||
|
successfully called.
|
||||||
|
|
||||||
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._instances[job_id] -= 1
|
self._instances[job_id] -= 1
|
||||||
|
if self._instances[job_id] == 0:
|
||||||
|
del self._instances[job_id]
|
||||||
|
|
||||||
for event in events:
|
for event in events:
|
||||||
self._scheduler._dispatch_event(event)
|
self._scheduler._dispatch_event(event)
|
||||||
|
|
||||||
def _run_job_error(self, job_id, exc, traceback=None):
|
def _run_job_error(self, job_id, exc, traceback=None):
|
||||||
"""Called by the executor with the exception if there is an error calling `run_job`."""
|
"""Called by the executor with the exception if there is an error calling `run_job`."""
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._instances[job_id] -= 1
|
self._instances[job_id] -= 1
|
||||||
|
if self._instances[job_id] == 0:
|
||||||
|
del self._instances[job_id]
|
||||||
|
|
||||||
exc_info = (exc.__class__, exc, traceback)
|
exc_info = (exc.__class__, exc, traceback)
|
||||||
self._logger.error('Error running job %s', job_id, exc_info=exc_info)
|
self._logger.error('Error running job %s', job_id, exc_info=exc_info)
|
||||||
|
|
||||||
|
|
||||||
def run_job(job, jobstore_alias, run_times, logger_name):
|
def run_job(job, jobstore_alias, run_times, logger_name):
|
||||||
"""Called by executors to run the job. Returns a list of scheduler events to be dispatched by the scheduler."""
|
"""
|
||||||
|
Called by executors to run the job. Returns a list of scheduler events to be dispatched by the
|
||||||
|
scheduler.
|
||||||
|
|
||||||
|
"""
|
||||||
events = []
|
events = []
|
||||||
logger = logging.getLogger(logger_name)
|
logger = logging.getLogger(logger_name)
|
||||||
for run_time in run_times:
|
for run_time in run_times:
|
||||||
# See if the job missed its run time window, and handle 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:
|
if job.misfire_grace_time is not None:
|
||||||
difference = datetime.now(utc) - run_time
|
difference = datetime.now(utc) - run_time
|
||||||
grace_time = timedelta(seconds=job.misfire_grace_time)
|
grace_time = timedelta(seconds=job.misfire_grace_time)
|
||||||
if difference > grace_time:
|
if difference > grace_time:
|
||||||
events.append(JobExecutionEvent(EVENT_JOB_MISSED, job.id, jobstore_alias, 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)
|
logger.warning('Run time of job "%s" was missed by %s', job, difference)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info('Running job "%s" (scheduled at %s)', job, run_time)
|
logger.info('Running job "%s" (scheduled at %s)', job, run_time)
|
||||||
try:
|
try:
|
||||||
retval = job.func(*job.args, **job.kwargs)
|
retval = job.func(*job.args, **job.kwargs)
|
||||||
except:
|
except BaseException:
|
||||||
exc, tb = sys.exc_info()[1:]
|
exc, tb = sys.exc_info()[1:]
|
||||||
formatted_tb = ''.join(format_tb(tb))
|
formatted_tb = ''.join(format_tb(tb))
|
||||||
events.append(JobExecutionEvent(EVENT_JOB_ERROR, job.id, jobstore_alias, run_time, exception=exc,
|
events.append(JobExecutionEvent(EVENT_JOB_ERROR, job.id, jobstore_alias, run_time,
|
||||||
traceback=formatted_tb))
|
exception=exc, traceback=formatted_tb))
|
||||||
logger.exception('Job "%s" raised an exception', job)
|
logger.exception('Job "%s" raised an exception', job)
|
||||||
|
|
||||||
|
# This is to prevent cyclic references that would lead to memory leaks
|
||||||
|
if six.PY2:
|
||||||
|
sys.exc_clear()
|
||||||
|
del tb
|
||||||
|
else:
|
||||||
|
import traceback
|
||||||
|
traceback.clear_frames(tb)
|
||||||
|
del tb
|
||||||
else:
|
else:
|
||||||
events.append(JobExecutionEvent(EVENT_JOB_EXECUTED, job.id, jobstore_alias, run_time, retval=retval))
|
events.append(JobExecutionEvent(EVENT_JOB_EXECUTED, job.id, jobstore_alias, run_time,
|
||||||
|
retval=retval))
|
||||||
logger.info('Job "%s" executed successfully', job)
|
logger.info('Job "%s" executed successfully', job)
|
||||||
|
|
||||||
return events
|
return events
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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,7 +5,8 @@ from apscheduler.executors.base import BaseExecutor, run_job
|
|||||||
|
|
||||||
class DebugExecutor(BaseExecutor):
|
class DebugExecutor(BaseExecutor):
|
||||||
"""
|
"""
|
||||||
A special executor that executes the target callable directly instead of deferring it to a thread or process.
|
A special executor that executes the target callable directly instead of deferring it to a
|
||||||
|
thread or process.
|
||||||
|
|
||||||
Plugin alias: ``debug``
|
Plugin alias: ``debug``
|
||||||
"""
|
"""
|
||||||
@@ -13,7 +14,7 @@ class DebugExecutor(BaseExecutor):
|
|||||||
def _do_submit_job(self, job, run_times):
|
def _do_submit_job(self, job, run_times):
|
||||||
try:
|
try:
|
||||||
events = run_job(job, job._jobstore_alias, run_times, self._logger.name)
|
events = run_job(job, job._jobstore_alias, run_times, self._logger.name)
|
||||||
except:
|
except BaseException:
|
||||||
self._run_job_error(job.id, *sys.exc_info()[1:])
|
self._run_job_error(job.id, *sys.exc_info()[1:])
|
||||||
else:
|
else:
|
||||||
self._run_job_success(job.id, events)
|
self._run_job_success(job.id, events)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from apscheduler.executors.base import BaseExecutor, run_job
|
from apscheduler.executors.base import BaseExecutor, run_job
|
||||||
@@ -21,9 +21,10 @@ class GeventExecutor(BaseExecutor):
|
|||||||
def callback(greenlet):
|
def callback(greenlet):
|
||||||
try:
|
try:
|
||||||
events = greenlet.get()
|
events = greenlet.get()
|
||||||
except:
|
except BaseException:
|
||||||
self._run_job_error(job.id, *sys.exc_info()[1:])
|
self._run_job_error(job.id, *sys.exc_info()[1:])
|
||||||
else:
|
else:
|
||||||
self._run_job_success(job.id, events)
|
self._run_job_success(job.id, events)
|
||||||
|
|
||||||
gevent.spawn(run_job, job, job._jobstore_alias, run_times, self._logger.name).link(callback)
|
gevent.spawn(run_job, job, job._jobstore_alias, run_times, self._logger.name).\
|
||||||
|
link(callback)
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import concurrent.futures
|
|||||||
|
|
||||||
from apscheduler.executors.base import BaseExecutor, run_job
|
from apscheduler.executors.base import BaseExecutor, run_job
|
||||||
|
|
||||||
|
try:
|
||||||
|
from concurrent.futures.process import BrokenProcessPool
|
||||||
|
except ImportError:
|
||||||
|
BrokenProcessPool = None
|
||||||
|
|
||||||
|
|
||||||
class BasePoolExecutor(BaseExecutor):
|
class BasePoolExecutor(BaseExecutor):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -19,7 +24,13 @@ class BasePoolExecutor(BaseExecutor):
|
|||||||
else:
|
else:
|
||||||
self._run_job_success(job.id, f.result())
|
self._run_job_success(job.id, f.result())
|
||||||
|
|
||||||
f = self._pool.submit(run_job, job, job._jobstore_alias, run_times, self._logger.name)
|
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.add_done_callback(callback)
|
f.add_done_callback(callback)
|
||||||
|
|
||||||
def shutdown(self, wait=True):
|
def shutdown(self, wait=True):
|
||||||
@@ -33,10 +44,13 @@ class ThreadPoolExecutor(BasePoolExecutor):
|
|||||||
Plugin alias: ``threadpool``
|
Plugin alias: ``threadpool``
|
||||||
|
|
||||||
:param max_workers: the maximum number of spawned threads.
|
:param max_workers: the maximum number of spawned threads.
|
||||||
|
:param pool_kwargs: dict of keyword arguments to pass to the underlying
|
||||||
|
ThreadPoolExecutor constructor
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, max_workers=10):
|
def __init__(self, max_workers=10, pool_kwargs=None):
|
||||||
pool = concurrent.futures.ThreadPoolExecutor(int(max_workers))
|
pool_kwargs = pool_kwargs or {}
|
||||||
|
pool = concurrent.futures.ThreadPoolExecutor(int(max_workers), **pool_kwargs)
|
||||||
super(ThreadPoolExecutor, self).__init__(pool)
|
super(ThreadPoolExecutor, self).__init__(pool)
|
||||||
|
|
||||||
|
|
||||||
@@ -47,8 +61,11 @@ class ProcessPoolExecutor(BasePoolExecutor):
|
|||||||
Plugin alias: ``processpool``
|
Plugin alias: ``processpool``
|
||||||
|
|
||||||
:param max_workers: the maximum number of spawned processes.
|
:param max_workers: the maximum number of spawned processes.
|
||||||
|
:param pool_kwargs: dict of keyword arguments to pass to the underlying
|
||||||
|
ProcessPoolExecutor constructor
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, max_workers=10):
|
def __init__(self, max_workers=10, pool_kwargs=None):
|
||||||
pool = concurrent.futures.ProcessPoolExecutor(int(max_workers))
|
pool_kwargs = pool_kwargs or {}
|
||||||
|
pool = concurrent.futures.ProcessPoolExecutor(int(max_workers), **pool_kwargs)
|
||||||
super(ProcessPoolExecutor, self).__init__(pool)
|
super(ProcessPoolExecutor, self).__init__(pool)
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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
|
from apscheduler.executors.base import BaseExecutor, run_job
|
||||||
|
|
||||||
@@ -21,5 +21,5 @@ class TwistedExecutor(BaseExecutor):
|
|||||||
else:
|
else:
|
||||||
self._run_job_error(job.id, result.value, result.tb)
|
self._run_job_error(job.id, result.value, result.tb)
|
||||||
|
|
||||||
self._reactor.getThreadPool().callInThreadWithCallback(callback, run_job, job, job._jobstore_alias, run_times,
|
self._reactor.getThreadPool().callInThreadWithCallback(
|
||||||
self._logger.name)
|
callback, run_job, job, job._jobstore_alias, run_times, self._logger.name)
|
||||||
|
|||||||
+77
-27
@@ -1,11 +1,17 @@
|
|||||||
from collections.abc import Iterable, Mapping
|
from inspect import ismethod, isclass
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import six
|
import six
|
||||||
|
|
||||||
from apscheduler.triggers.base import BaseTrigger
|
from apscheduler.triggers.base import BaseTrigger
|
||||||
from apscheduler.util import ref_to_obj, obj_to_ref, datetime_repr, repr_escape, get_callable_name, check_callable_args, \
|
from apscheduler.util import (
|
||||||
convert_to_datetime
|
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
|
||||||
|
|
||||||
|
|
||||||
class Job(object):
|
class Job(object):
|
||||||
@@ -21,13 +27,20 @@ class Job(object):
|
|||||||
:var bool coalesce: whether to only run the job once when several run times are due
|
:var bool coalesce: whether to only run the job once when several run times are due
|
||||||
:var trigger: the trigger object that controls the schedule of this job
|
:var trigger: the trigger object that controls the schedule of this job
|
||||||
:var str executor: the name of the executor that will run this job
|
:var str executor: the name of the executor that will run this job
|
||||||
:var int misfire_grace_time: the time (in seconds) how much this job's execution is allowed to be late
|
:var int misfire_grace_time: the time (in seconds) how much this job's execution is allowed to
|
||||||
:var int max_instances: the maximum number of concurrently executing instances allowed for this job
|
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 datetime.datetime next_run_time: the next scheduled run time of this job
|
:var datetime.datetime next_run_time: the next scheduled run time of this job
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
The ``misfire_grace_time`` has some non-obvious effects on job execution. See the
|
||||||
|
:ref:`missed-job-executions` section in the documentation for an in-depth explanation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = ('_scheduler', '_jobstore_alias', 'id', 'trigger', 'executor', 'func', 'func_ref', 'args', 'kwargs',
|
__slots__ = ('_scheduler', '_jobstore_alias', 'id', 'trigger', 'executor', 'func', 'func_ref',
|
||||||
'name', 'misfire_grace_time', 'coalesce', 'max_instances', 'next_run_time')
|
'args', 'kwargs', 'name', 'misfire_grace_time', 'coalesce', 'max_instances',
|
||||||
|
'next_run_time', '__weakref__')
|
||||||
|
|
||||||
def __init__(self, scheduler, id=None, **kwargs):
|
def __init__(self, scheduler, id=None, **kwargs):
|
||||||
super(Job, self).__init__()
|
super(Job, self).__init__()
|
||||||
@@ -38,53 +51,69 @@ class Job(object):
|
|||||||
def modify(self, **changes):
|
def modify(self, **changes):
|
||||||
"""
|
"""
|
||||||
Makes the given changes to this job and saves it in the associated job store.
|
Makes the given changes to this job and saves it in the associated job store.
|
||||||
|
|
||||||
Accepted keyword arguments are the same as the variables on this class.
|
Accepted keyword arguments are the same as the variables on this class.
|
||||||
|
|
||||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.modify_job`
|
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.modify_job`
|
||||||
"""
|
|
||||||
|
|
||||||
|
:return Job: this job instance
|
||||||
|
|
||||||
|
"""
|
||||||
self._scheduler.modify_job(self.id, self._jobstore_alias, **changes)
|
self._scheduler.modify_job(self.id, self._jobstore_alias, **changes)
|
||||||
|
return self
|
||||||
|
|
||||||
def reschedule(self, trigger, **trigger_args):
|
def reschedule(self, trigger, **trigger_args):
|
||||||
"""
|
"""
|
||||||
Shortcut for switching the trigger on this job.
|
Shortcut for switching the trigger on this job.
|
||||||
|
|
||||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.reschedule_job`
|
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.reschedule_job`
|
||||||
"""
|
|
||||||
|
|
||||||
|
:return Job: this job instance
|
||||||
|
|
||||||
|
"""
|
||||||
self._scheduler.reschedule_job(self.id, self._jobstore_alias, trigger, **trigger_args)
|
self._scheduler.reschedule_job(self.id, self._jobstore_alias, trigger, **trigger_args)
|
||||||
|
return self
|
||||||
|
|
||||||
def pause(self):
|
def pause(self):
|
||||||
"""
|
"""
|
||||||
Temporarily suspend the execution of this job.
|
Temporarily suspend the execution of this job.
|
||||||
|
|
||||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.pause_job`
|
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.pause_job`
|
||||||
"""
|
|
||||||
|
|
||||||
|
:return Job: this job instance
|
||||||
|
|
||||||
|
"""
|
||||||
self._scheduler.pause_job(self.id, self._jobstore_alias)
|
self._scheduler.pause_job(self.id, self._jobstore_alias)
|
||||||
|
return self
|
||||||
|
|
||||||
def resume(self):
|
def resume(self):
|
||||||
"""
|
"""
|
||||||
Resume the schedule of this job if previously paused.
|
Resume the schedule of this job if previously paused.
|
||||||
|
|
||||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.resume_job`
|
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.resume_job`
|
||||||
"""
|
|
||||||
|
|
||||||
|
:return Job: this job instance
|
||||||
|
|
||||||
|
"""
|
||||||
self._scheduler.resume_job(self.id, self._jobstore_alias)
|
self._scheduler.resume_job(self.id, self._jobstore_alias)
|
||||||
|
return self
|
||||||
|
|
||||||
def remove(self):
|
def remove(self):
|
||||||
"""
|
"""
|
||||||
Unschedules this job and removes it from its associated job store.
|
Unschedules this job and removes it from its associated job store.
|
||||||
|
|
||||||
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.remove_job`
|
.. seealso:: :meth:`~apscheduler.schedulers.base.BaseScheduler.remove_job`
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
self._scheduler.remove_job(self.id, self._jobstore_alias)
|
self._scheduler.remove_job(self.id, self._jobstore_alias)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pending(self):
|
def pending(self):
|
||||||
"""Returns ``True`` if the referenced job is still waiting to be added to its designated job store."""
|
"""
|
||||||
|
Returns ``True`` if the referenced job is still waiting to be added to its designated job
|
||||||
|
store.
|
||||||
|
|
||||||
|
"""
|
||||||
return self._jobstore_alias is None
|
return self._jobstore_alias is None
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -97,8 +126,8 @@ class Job(object):
|
|||||||
|
|
||||||
:type now: datetime.datetime
|
:type now: datetime.datetime
|
||||||
:rtype: list[datetime.datetime]
|
:rtype: list[datetime.datetime]
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
run_times = []
|
run_times = []
|
||||||
next_run_time = self.next_run_time
|
next_run_time = self.next_run_time
|
||||||
while next_run_time and next_run_time <= now:
|
while next_run_time and next_run_time <= now:
|
||||||
@@ -108,8 +137,11 @@ class Job(object):
|
|||||||
return run_times
|
return run_times
|
||||||
|
|
||||||
def _modify(self, **changes):
|
def _modify(self, **changes):
|
||||||
"""Validates the changes to the Job and makes the modifications if and only if all of them validate."""
|
"""
|
||||||
|
Validates the changes to the Job and makes the modifications if and only if all of them
|
||||||
|
validate.
|
||||||
|
|
||||||
|
"""
|
||||||
approved = {}
|
approved = {}
|
||||||
|
|
||||||
if 'id' in changes:
|
if 'id' in changes:
|
||||||
@@ -125,7 +157,7 @@ class Job(object):
|
|||||||
args = changes.pop('args') if 'args' in changes else self.args
|
args = changes.pop('args') if 'args' in changes else self.args
|
||||||
kwargs = changes.pop('kwargs') if 'kwargs' in changes else self.kwargs
|
kwargs = changes.pop('kwargs') if 'kwargs' in changes else self.kwargs
|
||||||
|
|
||||||
if isinstance(func, str):
|
if isinstance(func, six.string_types):
|
||||||
func_ref = func
|
func_ref = func
|
||||||
func = ref_to_obj(func)
|
func = ref_to_obj(func)
|
||||||
elif callable(func):
|
elif callable(func):
|
||||||
@@ -177,7 +209,8 @@ class Job(object):
|
|||||||
if 'trigger' in changes:
|
if 'trigger' in changes:
|
||||||
trigger = changes.pop('trigger')
|
trigger = changes.pop('trigger')
|
||||||
if not isinstance(trigger, BaseTrigger):
|
if not isinstance(trigger, BaseTrigger):
|
||||||
raise TypeError('Expected a trigger instance, got %s instead' % trigger.__class__.__name__)
|
raise TypeError('Expected a trigger instance, got %s instead' %
|
||||||
|
trigger.__class__.__name__)
|
||||||
|
|
||||||
approved['trigger'] = trigger
|
approved['trigger'] = trigger
|
||||||
|
|
||||||
@@ -189,10 +222,12 @@ class Job(object):
|
|||||||
|
|
||||||
if 'next_run_time' in changes:
|
if 'next_run_time' in changes:
|
||||||
value = changes.pop('next_run_time')
|
value = changes.pop('next_run_time')
|
||||||
approved['next_run_time'] = convert_to_datetime(value, self._scheduler.timezone, 'next_run_time')
|
approved['next_run_time'] = convert_to_datetime(value, self._scheduler.timezone,
|
||||||
|
'next_run_time')
|
||||||
|
|
||||||
if changes:
|
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):
|
for key, value in six.iteritems(approved):
|
||||||
setattr(self, key, value)
|
setattr(self, key, value)
|
||||||
@@ -200,9 +235,18 @@ class Job(object):
|
|||||||
def __getstate__(self):
|
def __getstate__(self):
|
||||||
# Don't allow this Job to be serialized if the function reference could not be determined
|
# Don't allow this Job to be serialized if the function reference could not be determined
|
||||||
if not self.func_ref:
|
if not self.func_ref:
|
||||||
raise ValueError('This Job cannot be serialized since the reference to its callable (%r) could not be '
|
raise ValueError(
|
||||||
'determined. Consider giving a textual reference (module:function name) instead.' %
|
'This Job cannot be serialized since the reference to its callable (%r) could not '
|
||||||
(self.func,))
|
'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
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'version': 1,
|
'version': 1,
|
||||||
@@ -210,7 +254,7 @@ class Job(object):
|
|||||||
'func': self.func_ref,
|
'func': self.func_ref,
|
||||||
'trigger': self.trigger,
|
'trigger': self.trigger,
|
||||||
'executor': self.executor,
|
'executor': self.executor,
|
||||||
'args': self.args,
|
'args': args,
|
||||||
'kwargs': self.kwargs,
|
'kwargs': self.kwargs,
|
||||||
'name': self.name,
|
'name': self.name,
|
||||||
'misfire_grace_time': self.misfire_grace_time,
|
'misfire_grace_time': self.misfire_grace_time,
|
||||||
@@ -221,7 +265,8 @@ class Job(object):
|
|||||||
|
|
||||||
def __setstate__(self, state):
|
def __setstate__(self, state):
|
||||||
if state.get('version', 1) > 1:
|
if state.get('version', 1) > 1:
|
||||||
raise ValueError('Job has version %s, but only version 1 can be handled' % state['version'])
|
raise ValueError('Job has version %s, but only version 1 can be handled' %
|
||||||
|
state['version'])
|
||||||
|
|
||||||
self.id = state['id']
|
self.id = state['id']
|
||||||
self.func_ref = state['func']
|
self.func_ref = state['func']
|
||||||
@@ -245,8 +290,13 @@ class Job(object):
|
|||||||
return '<Job (id=%s name=%s)>' % (repr_escape(self.id), repr_escape(self.name))
|
return '<Job (id=%s name=%s)>' % (repr_escape(self.id), repr_escape(self.name))
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '%s (trigger: %s, next run at: %s)' % (repr_escape(self.name), repr_escape(str(self.trigger)),
|
return repr_escape(self.__unicode__())
|
||||||
datetime_repr(self.next_run_time))
|
|
||||||
|
|
||||||
def __unicode__(self):
|
def __unicode__(self):
|
||||||
return six.u('%s (trigger: %s, next run at: %s)') % (self.name, self.trigger, datetime_repr(self.next_run_time))
|
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)
|
||||||
|
|||||||
@@ -8,23 +8,27 @@ class JobLookupError(KeyError):
|
|||||||
"""Raised when the job store cannot find a job for update or removal."""
|
"""Raised when the job store cannot find a job for update or removal."""
|
||||||
|
|
||||||
def __init__(self, job_id):
|
def __init__(self, job_id):
|
||||||
super(JobLookupError, self).__init__(six.u('No job by the id of %s was found') % job_id)
|
super(JobLookupError, self).__init__(u'No job by the id of %s was found' % job_id)
|
||||||
|
|
||||||
|
|
||||||
class ConflictingIdError(KeyError):
|
class ConflictingIdError(KeyError):
|
||||||
"""Raised when the uniqueness of job IDs is being violated."""
|
"""Raised when the uniqueness of job IDs is being violated."""
|
||||||
|
|
||||||
def __init__(self, job_id):
|
def __init__(self, job_id):
|
||||||
super(ConflictingIdError, self).__init__(six.u('Job identifier (%s) conflicts with an existing job') % job_id)
|
super(ConflictingIdError, self).__init__(
|
||||||
|
u'Job identifier (%s) conflicts with an existing job' % job_id)
|
||||||
|
|
||||||
|
|
||||||
class TransientJobError(ValueError):
|
class TransientJobError(ValueError):
|
||||||
"""Raised when an attempt to add transient (with no func_ref) job to a persistent job store is detected."""
|
"""
|
||||||
|
Raised when an attempt to add transient (with no func_ref) job to a persistent job store is
|
||||||
|
detected.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, job_id):
|
def __init__(self, job_id):
|
||||||
super(TransientJobError, self).__init__(
|
super(TransientJobError, self).__init__(
|
||||||
six.u('Job (%s) cannot be added to this job store because a reference to the callable could not be '
|
u'Job (%s) cannot be added to this job store because a reference to the callable '
|
||||||
'determined.') % job_id)
|
u'could not be determined.' % job_id)
|
||||||
|
|
||||||
|
|
||||||
class BaseJobStore(six.with_metaclass(ABCMeta)):
|
class BaseJobStore(six.with_metaclass(ABCMeta)):
|
||||||
@@ -36,10 +40,11 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
|
|||||||
|
|
||||||
def start(self, scheduler, alias):
|
def start(self, scheduler, alias):
|
||||||
"""
|
"""
|
||||||
Called by the scheduler when the scheduler is being started or when the job store is being added to an already
|
Called by the scheduler when the scheduler is being started or when the job store is being
|
||||||
running scheduler.
|
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
|
:param str|unicode alias: alias of this job store as it was assigned to the scheduler
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -50,13 +55,22 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
|
|||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
"""Frees any resources still bound to this job store."""
|
"""Frees any resources still bound to this job store."""
|
||||||
|
|
||||||
|
def _fix_paused_jobs_sorting(self, jobs):
|
||||||
|
for i, job in enumerate(jobs):
|
||||||
|
if job.next_run_time is not None:
|
||||||
|
if i > 0:
|
||||||
|
paused_jobs = jobs[:i]
|
||||||
|
del jobs[:i]
|
||||||
|
jobs.extend(paused_jobs)
|
||||||
|
break
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def lookup_job(self, job_id):
|
def lookup_job(self, job_id):
|
||||||
"""
|
"""
|
||||||
Returns a specific job, or ``None`` if it isn't found..
|
Returns a specific job, or ``None`` if it isn't found..
|
||||||
|
|
||||||
The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of the returned job to
|
The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of
|
||||||
point to the scheduler and itself, respectively.
|
the returned job to point to the scheduler and itself, respectively.
|
||||||
|
|
||||||
:param str|unicode job_id: identifier of the job
|
:param str|unicode job_id: identifier of the job
|
||||||
:rtype: Job
|
:rtype: Job
|
||||||
@@ -75,7 +89,8 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_next_run_time(self):
|
def get_next_run_time(self):
|
||||||
"""
|
"""
|
||||||
Returns the earliest run time of all the jobs stored in this job store, or ``None`` if 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
|
:rtype: datetime.datetime
|
||||||
"""
|
"""
|
||||||
@@ -83,11 +98,12 @@ class BaseJobStore(six.with_metaclass(ABCMeta)):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_all_jobs(self):
|
def get_all_jobs(self):
|
||||||
"""
|
"""
|
||||||
Returns a list of all jobs in this job store. The returned jobs should be sorted by next run time (ascending).
|
Returns a list of all jobs in this job store.
|
||||||
Paused jobs (next_run_time is None) should be sorted last.
|
The returned jobs should be sorted by next run time (ascending).
|
||||||
|
Paused jobs (next_run_time == None) should be sorted last.
|
||||||
|
|
||||||
The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of the returned jobs to
|
The job store is responsible for setting the ``scheduler`` and ``jobstore`` attributes of
|
||||||
point to the scheduler and itself, respectively.
|
the returned jobs to point to the scheduler and itself, respectively.
|
||||||
|
|
||||||
:rtype: list[Job]
|
:rtype: list[Job]
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
||||||
from apscheduler.util import datetime_to_utc_timestamp
|
from apscheduler.util import datetime_to_utc_timestamp
|
||||||
@@ -13,7 +13,8 @@ class MemoryJobStore(BaseJobStore):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super(MemoryJobStore, self).__init__()
|
super(MemoryJobStore, self).__init__()
|
||||||
self._jobs = [] # list of (job, timestamp), sorted by next_run_time and job id (ascending)
|
# list of (job, timestamp), sorted by next_run_time and job id (ascending)
|
||||||
|
self._jobs = []
|
||||||
self._jobs_index = {} # id -> (job, timestamp) lookup table
|
self._jobs_index = {} # id -> (job, timestamp) lookup table
|
||||||
|
|
||||||
def lookup_job(self, job_id):
|
def lookup_job(self, job_id):
|
||||||
@@ -80,13 +81,13 @@ class MemoryJobStore(BaseJobStore):
|
|||||||
|
|
||||||
def _get_job_index(self, timestamp, job_id):
|
def _get_job_index(self, timestamp, job_id):
|
||||||
"""
|
"""
|
||||||
Returns the index of the given job, or if it's not found, the index where the job should be inserted based on
|
Returns the index of the given job, or if it's not found, the index where the job should be
|
||||||
the given timestamp.
|
inserted based on the given timestamp.
|
||||||
|
|
||||||
:type timestamp: int
|
:type timestamp: int
|
||||||
:type job_id: str
|
:type job_id: str
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
lo, hi = 0, len(self._jobs)
|
lo, hi = 0, len(self._jobs)
|
||||||
timestamp = float('inf') if timestamp is None else timestamp
|
timestamp = float('inf') if timestamp is None else timestamp
|
||||||
while lo < hi:
|
while lo < hi:
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
import warnings
|
||||||
|
|
||||||
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
||||||
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
|
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
|
||||||
from apscheduler.job import Job
|
from apscheduler.job import Job
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pickle as pickle
|
import cPickle as pickle
|
||||||
except ImportError: # pragma: nocover
|
except ImportError: # pragma: nocover
|
||||||
import pickle
|
import pickle
|
||||||
|
|
||||||
@@ -19,16 +20,18 @@ except ImportError: # pragma: nocover
|
|||||||
|
|
||||||
class MongoDBJobStore(BaseJobStore):
|
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>`_.
|
<http://api.mongodb.org/python/current/api/pymongo/mongo_client.html#pymongo.mongo_client.MongoClient>`_.
|
||||||
|
|
||||||
Plugin alias: ``mongodb``
|
Plugin alias: ``mongodb``
|
||||||
|
|
||||||
:param str database: database to store jobs in
|
:param str database: database to store jobs in
|
||||||
:param str collection: collection to store jobs in
|
:param str collection: collection to store jobs in
|
||||||
:param client: a :class:`~pymongo.mongo_client.MongoClient` instance to use instead of providing connection
|
:param client: a :class:`~pymongo.mongo_client.MongoClient` instance to use instead of
|
||||||
arguments
|
providing connection arguments
|
||||||
: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, database='apscheduler', collection='jobs', client=None,
|
def __init__(self, database='apscheduler', collection='jobs', client=None,
|
||||||
@@ -42,13 +45,22 @@ class MongoDBJobStore(BaseJobStore):
|
|||||||
raise ValueError('The "collection" parameter must not be empty')
|
raise ValueError('The "collection" parameter must not be empty')
|
||||||
|
|
||||||
if client:
|
if client:
|
||||||
self.connection = maybe_ref(client)
|
self.client = maybe_ref(client)
|
||||||
else:
|
else:
|
||||||
connect_args.setdefault('w', 1)
|
connect_args.setdefault('w', 1)
|
||||||
self.connection = MongoClient(**connect_args)
|
self.client = MongoClient(**connect_args)
|
||||||
|
|
||||||
self.collection = self.connection[database][collection]
|
self.collection = self.client[database][collection]
|
||||||
self.collection.ensure_index('next_run_time', sparse=True)
|
|
||||||
|
def start(self, scheduler, alias):
|
||||||
|
super(MongoDBJobStore, self).start(scheduler, alias)
|
||||||
|
self.collection.create_index('next_run_time', sparse=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connection(self):
|
||||||
|
warnings.warn('The "connection" member is deprecated -- use "client" instead',
|
||||||
|
DeprecationWarning)
|
||||||
|
return self.client
|
||||||
|
|
||||||
def lookup_job(self, job_id):
|
def lookup_job(self, job_id):
|
||||||
document = self.collection.find_one(job_id, ['job_state'])
|
document = self.collection.find_one(job_id, ['job_state'])
|
||||||
@@ -59,16 +71,19 @@ class MongoDBJobStore(BaseJobStore):
|
|||||||
return self._get_jobs({'next_run_time': {'$lte': timestamp}})
|
return self._get_jobs({'next_run_time': {'$lte': timestamp}})
|
||||||
|
|
||||||
def get_next_run_time(self):
|
def get_next_run_time(self):
|
||||||
document = self.collection.find_one({'next_run_time': {'$ne': None}}, fields=['next_run_time'],
|
document = self.collection.find_one({'next_run_time': {'$ne': None}},
|
||||||
|
projection=['next_run_time'],
|
||||||
sort=[('next_run_time', ASCENDING)])
|
sort=[('next_run_time', ASCENDING)])
|
||||||
return utc_timestamp_to_datetime(document['next_run_time']) if document else None
|
return utc_timestamp_to_datetime(document['next_run_time']) if document else None
|
||||||
|
|
||||||
def get_all_jobs(self):
|
def get_all_jobs(self):
|
||||||
return self._get_jobs({})
|
jobs = self._get_jobs({})
|
||||||
|
self._fix_paused_jobs_sorting(jobs)
|
||||||
|
return jobs
|
||||||
|
|
||||||
def add_job(self, job):
|
def add_job(self, job):
|
||||||
try:
|
try:
|
||||||
self.collection.insert({
|
self.collection.insert_one({
|
||||||
'_id': job.id,
|
'_id': job.id,
|
||||||
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||||
'job_state': Binary(pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
'job_state': Binary(pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
||||||
@@ -81,20 +96,20 @@ class MongoDBJobStore(BaseJobStore):
|
|||||||
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||||
'job_state': Binary(pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
'job_state': Binary(pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
||||||
}
|
}
|
||||||
result = self.collection.update({'_id': job.id}, {'$set': changes})
|
result = self.collection.update_one({'_id': job.id}, {'$set': changes})
|
||||||
if result and result['n'] == 0:
|
if result and result.matched_count == 0:
|
||||||
raise JobLookupError(id)
|
raise JobLookupError(job.id)
|
||||||
|
|
||||||
def remove_job(self, job_id):
|
def remove_job(self, job_id):
|
||||||
result = self.collection.remove(job_id)
|
result = self.collection.delete_one({'_id': job_id})
|
||||||
if result and result['n'] == 0:
|
if result and result.deleted_count == 0:
|
||||||
raise JobLookupError(job_id)
|
raise JobLookupError(job_id)
|
||||||
|
|
||||||
def remove_all_jobs(self):
|
def remove_all_jobs(self):
|
||||||
self.collection.remove()
|
self.collection.delete_many({})
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
self.connection.disconnect()
|
self.client.close()
|
||||||
|
|
||||||
def _reconstitute_job(self, job_state):
|
def _reconstitute_job(self, job_state):
|
||||||
job_state = pickle.loads(job_state)
|
job_state = pickle.loads(job_state)
|
||||||
@@ -107,18 +122,20 @@ class MongoDBJobStore(BaseJobStore):
|
|||||||
def _get_jobs(self, conditions):
|
def _get_jobs(self, conditions):
|
||||||
jobs = []
|
jobs = []
|
||||||
failed_job_ids = []
|
failed_job_ids = []
|
||||||
for document in self.collection.find(conditions, ['_id', 'job_state'], sort=[('next_run_time', ASCENDING)]):
|
for document in self.collection.find(conditions, ['_id', 'job_state'],
|
||||||
|
sort=[('next_run_time', ASCENDING)]):
|
||||||
try:
|
try:
|
||||||
jobs.append(self._reconstitute_job(document['job_state']))
|
jobs.append(self._reconstitute_job(document['job_state']))
|
||||||
except:
|
except BaseException:
|
||||||
self._logger.exception('Unable to restore job "%s" -- removing it', document['_id'])
|
self._logger.exception('Unable to restore job "%s" -- removing it',
|
||||||
|
document['_id'])
|
||||||
failed_job_ids.append(document['_id'])
|
failed_job_ids.append(document['_id'])
|
||||||
|
|
||||||
# Remove all the jobs we failed to restore
|
# Remove all the jobs we failed to restore
|
||||||
if failed_job_ids:
|
if failed_job_ids:
|
||||||
self.collection.remove({'_id': {'$in': failed_job_ids}})
|
self.collection.delete_many({'_id': {'$in': failed_job_ids}})
|
||||||
|
|
||||||
return jobs
|
return jobs
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return '<%s (client=%s)>' % (self.__class__.__name__, self.connection)
|
return '<%s (client=%s)>' % (self.__class__.__name__, self.client)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pytz import utc
|
||||||
import six
|
import six
|
||||||
|
|
||||||
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
||||||
@@ -7,26 +9,28 @@ from apscheduler.util import datetime_to_utc_timestamp, utc_timestamp_to_datetim
|
|||||||
from apscheduler.job import Job
|
from apscheduler.job import Job
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pickle as pickle
|
import cPickle as pickle
|
||||||
except ImportError: # pragma: nocover
|
except ImportError: # pragma: nocover
|
||||||
import pickle
|
import pickle
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from redis import StrictRedis
|
from redis import Redis
|
||||||
except ImportError: # pragma: nocover
|
except ImportError: # pragma: nocover
|
||||||
raise ImportError('RedisJobStore requires redis installed')
|
raise ImportError('RedisJobStore requires redis installed')
|
||||||
|
|
||||||
|
|
||||||
class RedisJobStore(BaseJobStore):
|
class RedisJobStore(BaseJobStore):
|
||||||
"""
|
"""
|
||||||
Stores jobs in a Redis database. Any leftover keyword arguments are directly passed to redis's StrictRedis.
|
Stores jobs in a Redis database. Any leftover keyword arguments are directly passed to redis's
|
||||||
|
:class:`~redis.StrictRedis`.
|
||||||
|
|
||||||
Plugin alias: ``redis``
|
Plugin alias: ``redis``
|
||||||
|
|
||||||
:param int db: the database number to store jobs in
|
:param int db: the database number to store jobs in
|
||||||
:param str jobs_key: key to store jobs in
|
:param str jobs_key: key to store jobs in
|
||||||
:param str run_times_key: key to store the jobs' run times in
|
:param str run_times_key: key to store the jobs' run times in
|
||||||
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the 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',
|
def __init__(self, db=0, jobs_key='apscheduler.jobs', run_times_key='apscheduler.run_times',
|
||||||
@@ -43,7 +47,7 @@ class RedisJobStore(BaseJobStore):
|
|||||||
self.pickle_protocol = pickle_protocol
|
self.pickle_protocol = pickle_protocol
|
||||||
self.jobs_key = jobs_key
|
self.jobs_key = jobs_key
|
||||||
self.run_times_key = run_times_key
|
self.run_times_key = run_times_key
|
||||||
self.redis = StrictRedis(db=int(db), **connect_args)
|
self.redis = Redis(db=int(db), **connect_args)
|
||||||
|
|
||||||
def lookup_job(self, job_id):
|
def lookup_job(self, job_id):
|
||||||
job_state = self.redis.hget(self.jobs_key, job_id)
|
job_state = self.redis.hget(self.jobs_key, job_id)
|
||||||
@@ -65,7 +69,8 @@ class RedisJobStore(BaseJobStore):
|
|||||||
def get_all_jobs(self):
|
def get_all_jobs(self):
|
||||||
job_states = self.redis.hgetall(self.jobs_key)
|
job_states = self.redis.hgetall(self.jobs_key)
|
||||||
jobs = self._reconstitute_jobs(six.iteritems(job_states))
|
jobs = self._reconstitute_jobs(six.iteritems(job_states))
|
||||||
return sorted(jobs, key=lambda job: job.next_run_time)
|
paused_sort_key = datetime(9999, 12, 31, tzinfo=utc)
|
||||||
|
return sorted(jobs, key=lambda job: job.next_run_time or paused_sort_key)
|
||||||
|
|
||||||
def add_job(self, job):
|
def add_job(self, job):
|
||||||
if self.redis.hexists(self.jobs_key, job.id):
|
if self.redis.hexists(self.jobs_key, job.id):
|
||||||
@@ -73,8 +78,12 @@ class RedisJobStore(BaseJobStore):
|
|||||||
|
|
||||||
with self.redis.pipeline() as pipe:
|
with self.redis.pipeline() as pipe:
|
||||||
pipe.multi()
|
pipe.multi()
|
||||||
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(),
|
||||||
pipe.zadd(self.run_times_key, datetime_to_utc_timestamp(job.next_run_time), job.id)
|
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.execute()
|
pipe.execute()
|
||||||
|
|
||||||
def update_job(self, job):
|
def update_job(self, job):
|
||||||
@@ -82,11 +91,14 @@ class RedisJobStore(BaseJobStore):
|
|||||||
raise JobLookupError(job.id)
|
raise JobLookupError(job.id)
|
||||||
|
|
||||||
with self.redis.pipeline() as pipe:
|
with self.redis.pipeline() as pipe:
|
||||||
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(), self.pickle_protocol))
|
pipe.hset(self.jobs_key, job.id, pickle.dumps(job.__getstate__(),
|
||||||
|
self.pickle_protocol))
|
||||||
if job.next_run_time:
|
if job.next_run_time:
|
||||||
pipe.zadd(self.run_times_key, datetime_to_utc_timestamp(job.next_run_time), job.id)
|
pipe.zadd(self.run_times_key,
|
||||||
|
{job.id: datetime_to_utc_timestamp(job.next_run_time)})
|
||||||
else:
|
else:
|
||||||
pipe.zrem(self.run_times_key, job.id)
|
pipe.zrem(self.run_times_key, job.id)
|
||||||
|
|
||||||
pipe.execute()
|
pipe.execute()
|
||||||
|
|
||||||
def remove_job(self, job_id):
|
def remove_job(self, job_id):
|
||||||
@@ -121,7 +133,7 @@ class RedisJobStore(BaseJobStore):
|
|||||||
for job_id, job_state in job_states:
|
for job_id, job_state in job_states:
|
||||||
try:
|
try:
|
||||||
jobs.append(self._reconstitute_job(job_state))
|
jobs.append(self._reconstitute_job(job_state))
|
||||||
except:
|
except BaseException:
|
||||||
self._logger.exception('Unable to restore job "%s" -- removing it', job_id)
|
self._logger.exception('Unable to restore job "%s" -- removing it', job_id)
|
||||||
failed_job_ids.append(job_id)
|
failed_job_ids.append(job_id)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
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,38 +1,47 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
from apscheduler.jobstores.base import BaseJobStore, JobLookupError, ConflictingIdError
|
||||||
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
|
from apscheduler.util import maybe_ref, datetime_to_utc_timestamp, utc_timestamp_to_datetime
|
||||||
from apscheduler.job import Job
|
from apscheduler.job import Job
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pickle as pickle
|
import cPickle as pickle
|
||||||
except ImportError: # pragma: nocover
|
except ImportError: # pragma: nocover
|
||||||
import pickle
|
import pickle
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from sqlalchemy import create_engine, Table, Column, MetaData, Unicode, Float, LargeBinary, select
|
from sqlalchemy import (
|
||||||
|
create_engine, Table, Column, MetaData, Unicode, Float, LargeBinary, select, and_)
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.sql.expression import null
|
||||||
except ImportError: # pragma: nocover
|
except ImportError: # pragma: nocover
|
||||||
raise ImportError('SQLAlchemyJobStore requires SQLAlchemy installed')
|
raise ImportError('SQLAlchemyJobStore requires SQLAlchemy installed')
|
||||||
|
|
||||||
|
|
||||||
class SQLAlchemyJobStore(BaseJobStore):
|
class SQLAlchemyJobStore(BaseJobStore):
|
||||||
"""
|
"""
|
||||||
Stores jobs in a database table using SQLAlchemy. 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``
|
Plugin alias: ``sqlalchemy``
|
||||||
|
|
||||||
:param str url: connection string (see `SQLAlchemy documentation
|
:param str url: connection string (see
|
||||||
<http://docs.sqlalchemy.org/en/latest/core/engines.html?highlight=create_engine#database-urls>`_
|
:ref:`SQLAlchemy documentation <sqlalchemy:database_urls>` on this)
|
||||||
on this)
|
:param engine: an SQLAlchemy :class:`~sqlalchemy.engine.Engine` to use instead of creating a
|
||||||
:param engine: an SQLAlchemy Engine to use instead of creating a new one based on ``url``
|
new one based on ``url``
|
||||||
:param str tablename: name of the table to store jobs in
|
:param str tablename: name of the table to store jobs in
|
||||||
:param metadata: a :class:`~sqlalchemy.MetaData` instance to use instead of creating a new one
|
:param metadata: a :class:`~sqlalchemy.schema.MetaData` instance to use instead of creating a
|
||||||
:param int pickle_protocol: pickle protocol level to use (for serialization), defaults to the highest available
|
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)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, url=None, engine=None, tablename='apscheduler_jobs', metadata=None,
|
def __init__(self, url=None, engine=None, tablename='apscheduler_jobs', metadata=None,
|
||||||
pickle_protocol=pickle.HIGHEST_PROTOCOL):
|
pickle_protocol=pickle.HIGHEST_PROTOCOL, tableschema=None, engine_options=None):
|
||||||
super(SQLAlchemyJobStore, self).__init__()
|
super(SQLAlchemyJobStore, self).__init__()
|
||||||
self.pickle_protocol = pickle_protocol
|
self.pickle_protocol = pickle_protocol
|
||||||
metadata = maybe_ref(metadata) or MetaData()
|
metadata = maybe_ref(metadata) or MetaData()
|
||||||
@@ -40,37 +49,46 @@ class SQLAlchemyJobStore(BaseJobStore):
|
|||||||
if engine:
|
if engine:
|
||||||
self.engine = maybe_ref(engine)
|
self.engine = maybe_ref(engine)
|
||||||
elif url:
|
elif url:
|
||||||
self.engine = create_engine(url)
|
self.engine = create_engine(url, **(engine_options or {}))
|
||||||
else:
|
else:
|
||||||
raise ValueError('Need either "engine" or "url" defined')
|
raise ValueError('Need either "engine" or "url" defined')
|
||||||
|
|
||||||
# 191 = max key length in MySQL for InnoDB/utf8mb4 tables, 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(
|
self.jobs_t = Table(
|
||||||
tablename, metadata,
|
tablename, metadata,
|
||||||
Column('id', Unicode(191, _warn_on_bytestring=False), primary_key=True),
|
Column('id', Unicode(191), primary_key=True),
|
||||||
Column('next_run_time', Float(25), index=True),
|
Column('next_run_time', Float(25), index=True),
|
||||||
Column('job_state', LargeBinary, nullable=False)
|
Column('job_state', LargeBinary, nullable=False),
|
||||||
|
schema=tableschema
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def start(self, scheduler, alias):
|
||||||
|
super(SQLAlchemyJobStore, self).start(scheduler, alias)
|
||||||
self.jobs_t.create(self.engine, True)
|
self.jobs_t.create(self.engine, True)
|
||||||
|
|
||||||
def lookup_job(self, job_id):
|
def lookup_job(self, job_id):
|
||||||
selectable = select([self.jobs_t.c.job_state]).where(self.jobs_t.c.id == job_id)
|
selectable = select(self.jobs_t.c.job_state).where(self.jobs_t.c.id == job_id)
|
||||||
job_state = self.engine.execute(selectable).scalar()
|
with self.engine.begin() as connection:
|
||||||
return self._reconstitute_job(job_state) if job_state else None
|
job_state = connection.execute(selectable).scalar()
|
||||||
|
return self._reconstitute_job(job_state) if job_state else None
|
||||||
|
|
||||||
def get_due_jobs(self, now):
|
def get_due_jobs(self, now):
|
||||||
timestamp = datetime_to_utc_timestamp(now)
|
timestamp = datetime_to_utc_timestamp(now)
|
||||||
return self._get_jobs(self.jobs_t.c.next_run_time <= timestamp)
|
return self._get_jobs(self.jobs_t.c.next_run_time <= timestamp)
|
||||||
|
|
||||||
def get_next_run_time(self):
|
def get_next_run_time(self):
|
||||||
selectable = select([self.jobs_t.c.next_run_time]).where(self.jobs_t.c.next_run_time != None).\
|
selectable = select(self.jobs_t.c.next_run_time).\
|
||||||
|
where(self.jobs_t.c.next_run_time != null()).\
|
||||||
order_by(self.jobs_t.c.next_run_time).limit(1)
|
order_by(self.jobs_t.c.next_run_time).limit(1)
|
||||||
next_run_time = self.engine.execute(selectable).scalar()
|
with self.engine.begin() as connection:
|
||||||
return utc_timestamp_to_datetime(next_run_time)
|
next_run_time = connection.execute(selectable).scalar()
|
||||||
|
return utc_timestamp_to_datetime(next_run_time)
|
||||||
|
|
||||||
def get_all_jobs(self):
|
def get_all_jobs(self):
|
||||||
return self._get_jobs()
|
jobs = self._get_jobs()
|
||||||
|
self._fix_paused_jobs_sorting(jobs)
|
||||||
|
return jobs
|
||||||
|
|
||||||
def add_job(self, job):
|
def add_job(self, job):
|
||||||
insert = self.jobs_t.insert().values(**{
|
insert = self.jobs_t.insert().values(**{
|
||||||
@@ -78,29 +96,33 @@ class SQLAlchemyJobStore(BaseJobStore):
|
|||||||
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||||
'job_state': pickle.dumps(job.__getstate__(), self.pickle_protocol)
|
'job_state': pickle.dumps(job.__getstate__(), self.pickle_protocol)
|
||||||
})
|
})
|
||||||
try:
|
with self.engine.begin() as connection:
|
||||||
self.engine.execute(insert)
|
try:
|
||||||
except IntegrityError:
|
connection.execute(insert)
|
||||||
raise ConflictingIdError(job.id)
|
except IntegrityError:
|
||||||
|
raise ConflictingIdError(job.id)
|
||||||
|
|
||||||
def update_job(self, job):
|
def update_job(self, job):
|
||||||
update = self.jobs_t.update().values(**{
|
update = self.jobs_t.update().values(**{
|
||||||
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
'next_run_time': datetime_to_utc_timestamp(job.next_run_time),
|
||||||
'job_state': pickle.dumps(job.__getstate__(), self.pickle_protocol)
|
'job_state': pickle.dumps(job.__getstate__(), self.pickle_protocol)
|
||||||
}).where(self.jobs_t.c.id == job.id)
|
}).where(self.jobs_t.c.id == job.id)
|
||||||
result = self.engine.execute(update)
|
with self.engine.begin() as connection:
|
||||||
if result.rowcount == 0:
|
result = connection.execute(update)
|
||||||
raise JobLookupError(id)
|
if result.rowcount == 0:
|
||||||
|
raise JobLookupError(job.id)
|
||||||
|
|
||||||
def remove_job(self, job_id):
|
def remove_job(self, job_id):
|
||||||
delete = self.jobs_t.delete().where(self.jobs_t.c.id == job_id)
|
delete = self.jobs_t.delete().where(self.jobs_t.c.id == job_id)
|
||||||
result = self.engine.execute(delete)
|
with self.engine.begin() as connection:
|
||||||
if result.rowcount == 0:
|
result = connection.execute(delete)
|
||||||
raise JobLookupError(job_id)
|
if result.rowcount == 0:
|
||||||
|
raise JobLookupError(job_id)
|
||||||
|
|
||||||
def remove_all_jobs(self):
|
def remove_all_jobs(self):
|
||||||
delete = self.jobs_t.delete()
|
delete = self.jobs_t.delete()
|
||||||
self.engine.execute(delete)
|
with self.engine.begin() as connection:
|
||||||
|
connection.execute(delete)
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
self.engine.dispose()
|
self.engine.dispose()
|
||||||
@@ -116,20 +138,22 @@ class SQLAlchemyJobStore(BaseJobStore):
|
|||||||
|
|
||||||
def _get_jobs(self, *conditions):
|
def _get_jobs(self, *conditions):
|
||||||
jobs = []
|
jobs = []
|
||||||
selectable = select([self.jobs_t.c.id, self.jobs_t.c.job_state]).order_by(self.jobs_t.c.next_run_time)
|
selectable = select(self.jobs_t.c.id, self.jobs_t.c.job_state).\
|
||||||
selectable = selectable.where(*conditions) if conditions else selectable
|
order_by(self.jobs_t.c.next_run_time)
|
||||||
|
selectable = selectable.where(and_(*conditions)) if conditions else selectable
|
||||||
failed_job_ids = set()
|
failed_job_ids = set()
|
||||||
for row in self.engine.execute(selectable):
|
with self.engine.begin() as connection:
|
||||||
try:
|
for row in connection.execute(selectable):
|
||||||
jobs.append(self._reconstitute_job(row.job_state))
|
try:
|
||||||
except:
|
jobs.append(self._reconstitute_job(row.job_state))
|
||||||
self._logger.exception('Unable to restore job "%s" -- removing it', row.id)
|
except BaseException:
|
||||||
failed_job_ids.add(row.id)
|
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
|
# Remove all the jobs we failed to restore
|
||||||
if failed_job_ids:
|
if failed_job_ids:
|
||||||
delete = self.jobs_t.delete().where(self.jobs_t.c.id.in_(failed_job_ids))
|
delete = self.jobs_t.delete().where(self.jobs_t.c.id.in_(failed_job_ids))
|
||||||
self.engine.execute(delete)
|
connection.execute(delete)
|
||||||
|
|
||||||
return jobs
|
return jobs
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
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,22 +1,16 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
from functools import wraps
|
import asyncio
|
||||||
|
from functools import wraps, partial
|
||||||
|
|
||||||
from apscheduler.schedulers.base import BaseScheduler
|
from apscheduler.schedulers.base import BaseScheduler
|
||||||
from apscheduler.util import maybe_ref
|
from apscheduler.util import maybe_ref
|
||||||
|
|
||||||
try:
|
|
||||||
import asyncio
|
|
||||||
except ImportError: # pragma: nocover
|
|
||||||
try:
|
|
||||||
import trollius as asyncio
|
|
||||||
except ImportError:
|
|
||||||
raise ImportError('AsyncIOScheduler requires either Python 3.4 or the asyncio package installed')
|
|
||||||
|
|
||||||
|
|
||||||
def run_in_event_loop(func):
|
def run_in_event_loop(func):
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapper(self, *args, **kwargs):
|
def wrapper(self, *args, **kwargs):
|
||||||
self._eventloop.call_soon_threadsafe(func, self, *args, **kwargs)
|
wrapped = partial(func, self, *args, **kwargs)
|
||||||
|
self._eventloop.call_soon_threadsafe(wrapped)
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +18,8 @@ class AsyncIOScheduler(BaseScheduler):
|
|||||||
"""
|
"""
|
||||||
A scheduler that runs on an asyncio (:pep:`3156`) event loop.
|
A scheduler that runs on an asyncio (:pep:`3156`) event loop.
|
||||||
|
|
||||||
|
The default executor can run jobs based on native coroutines (``async def``).
|
||||||
|
|
||||||
Extra options:
|
Extra options:
|
||||||
|
|
||||||
============== =============================================================
|
============== =============================================================
|
||||||
@@ -34,9 +30,11 @@ class AsyncIOScheduler(BaseScheduler):
|
|||||||
_eventloop = None
|
_eventloop = None
|
||||||
_timeout = None
|
_timeout = None
|
||||||
|
|
||||||
def start(self):
|
def start(self, paused=False):
|
||||||
super(AsyncIOScheduler, self).start()
|
if not self._eventloop:
|
||||||
self.wakeup()
|
self._eventloop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
super(AsyncIOScheduler, self).start(paused)
|
||||||
|
|
||||||
@run_in_event_loop
|
@run_in_event_loop
|
||||||
def shutdown(self, wait=True):
|
def shutdown(self, wait=True):
|
||||||
@@ -44,7 +42,7 @@ class AsyncIOScheduler(BaseScheduler):
|
|||||||
self._stop_timer()
|
self._stop_timer()
|
||||||
|
|
||||||
def _configure(self, config):
|
def _configure(self, config):
|
||||||
self._eventloop = maybe_ref(config.pop('event_loop', None)) or asyncio.get_event_loop()
|
self._eventloop = maybe_ref(config.pop('event_loop', None))
|
||||||
super(AsyncIOScheduler, self)._configure(config)
|
super(AsyncIOScheduler, self)._configure(config)
|
||||||
|
|
||||||
def _start_timer(self, wait_seconds):
|
def _start_timer(self, wait_seconds):
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
from threading import Thread, Event
|
from threading import Thread, Event
|
||||||
|
|
||||||
@@ -13,11 +14,12 @@ class BackgroundScheduler(BlockingScheduler):
|
|||||||
|
|
||||||
Extra options:
|
Extra options:
|
||||||
|
|
||||||
========== ============================================================================================
|
========== =============================================================================
|
||||||
``daemon`` Set the ``daemon`` option in the background thread (defaults to ``True``,
|
``daemon`` Set the ``daemon`` option in the background thread (defaults to ``True``, see
|
||||||
see `the documentation <https://docs.python.org/3.4/library/threading.html#thread-objects>`_
|
`the documentation
|
||||||
|
<https://docs.python.org/3.4/library/threading.html#thread-objects>`_
|
||||||
for further details)
|
for further details)
|
||||||
========== ============================================================================================
|
========== =============================================================================
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_thread = None
|
_thread = None
|
||||||
@@ -26,14 +28,16 @@ class BackgroundScheduler(BlockingScheduler):
|
|||||||
self._daemon = asbool(config.pop('daemon', True))
|
self._daemon = asbool(config.pop('daemon', True))
|
||||||
super(BackgroundScheduler, self)._configure(config)
|
super(BackgroundScheduler, self)._configure(config)
|
||||||
|
|
||||||
def start(self):
|
def start(self, *args, **kwargs):
|
||||||
BaseScheduler.start(self)
|
if self._event is None or self._event.is_set():
|
||||||
self._event = Event()
|
self._event = Event()
|
||||||
|
|
||||||
|
BaseScheduler.start(self, *args, **kwargs)
|
||||||
self._thread = Thread(target=self._main_loop, name='APScheduler')
|
self._thread = Thread(target=self._main_loop, name='APScheduler')
|
||||||
self._thread.daemon = self._daemon
|
self._thread.daemon = self._daemon
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def shutdown(self, wait=True):
|
def shutdown(self, *args, **kwargs):
|
||||||
super(BackgroundScheduler, self).shutdown(wait)
|
super(BackgroundScheduler, self).shutdown(*args, **kwargs)
|
||||||
self._thread.join()
|
self._thread.join()
|
||||||
del self._thread
|
del self._thread
|
||||||
|
|||||||
+395
-214
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,23 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
from threading import Event
|
from threading import Event
|
||||||
|
|
||||||
from apscheduler.schedulers.base import BaseScheduler
|
from apscheduler.schedulers.base import BaseScheduler, STATE_STOPPED
|
||||||
|
from apscheduler.util import TIMEOUT_MAX
|
||||||
|
|
||||||
|
|
||||||
class BlockingScheduler(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
|
_event = None
|
||||||
|
|
||||||
def start(self):
|
def start(self, *args, **kwargs):
|
||||||
super(BlockingScheduler, self).start()
|
if self._event is None or self._event.is_set():
|
||||||
self._event = Event()
|
self._event = Event()
|
||||||
|
|
||||||
|
super(BlockingScheduler, self).start(*args, **kwargs)
|
||||||
self._main_loop()
|
self._main_loop()
|
||||||
|
|
||||||
def shutdown(self, wait=True):
|
def shutdown(self, wait=True):
|
||||||
@@ -23,10 +25,11 @@ class BlockingScheduler(BaseScheduler):
|
|||||||
self._event.set()
|
self._event.set()
|
||||||
|
|
||||||
def _main_loop(self):
|
def _main_loop(self):
|
||||||
while self.running:
|
wait_seconds = TIMEOUT_MAX
|
||||||
wait_seconds = self._process_jobs()
|
while self.state != STATE_STOPPED:
|
||||||
self._event.wait(wait_seconds if wait_seconds is not None else self.MAX_WAIT_TIME)
|
self._event.wait(wait_seconds)
|
||||||
self._event.clear()
|
self._event.clear()
|
||||||
|
wait_seconds = self._process_jobs()
|
||||||
|
|
||||||
def wakeup(self):
|
def wakeup(self):
|
||||||
self._event.set()
|
self._event.set()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
from apscheduler.schedulers.blocking import BlockingScheduler
|
from apscheduler.schedulers.blocking import BlockingScheduler
|
||||||
from apscheduler.schedulers.base import BaseScheduler
|
from apscheduler.schedulers.base import BaseScheduler
|
||||||
@@ -16,14 +16,14 @@ class GeventScheduler(BlockingScheduler):
|
|||||||
|
|
||||||
_greenlet = None
|
_greenlet = None
|
||||||
|
|
||||||
def start(self):
|
def start(self, *args, **kwargs):
|
||||||
BaseScheduler.start(self)
|
|
||||||
self._event = Event()
|
self._event = Event()
|
||||||
|
BaseScheduler.start(self, *args, **kwargs)
|
||||||
self._greenlet = gevent.spawn(self._main_loop)
|
self._greenlet = gevent.spawn(self._main_loop)
|
||||||
return self._greenlet
|
return self._greenlet
|
||||||
|
|
||||||
def shutdown(self, wait=True):
|
def shutdown(self, *args, **kwargs):
|
||||||
super(GeventScheduler, self).shutdown(wait)
|
super(GeventScheduler, self).shutdown(*args, **kwargs)
|
||||||
self._greenlet.join()
|
self._greenlet.join()
|
||||||
del self._greenlet
|
del self._greenlet
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
from apscheduler.schedulers.base import BaseScheduler
|
from apscheduler.schedulers.base import BaseScheduler
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from PyQt5.QtCore import QObject, QTimer
|
from PyQt5.QtCore import QObject, QTimer
|
||||||
except ImportError: # pragma: nocover
|
except (ImportError, RuntimeError): # pragma: nocover
|
||||||
try:
|
try:
|
||||||
from PyQt4.QtCore import QObject, QTimer
|
from PyQt4.QtCore import QObject, QTimer
|
||||||
except ImportError:
|
except ImportError:
|
||||||
try:
|
try:
|
||||||
from PySide.QtCore import QObject, QTimer # flake8: noqa
|
from PySide6.QtCore import QObject, QTimer # noqa
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ImportError('QtScheduler requires either PyQt5, PyQt4 or PySide installed')
|
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')
|
||||||
|
|
||||||
|
|
||||||
class QtScheduler(BaseScheduler):
|
class QtScheduler(BaseScheduler):
|
||||||
@@ -19,18 +26,15 @@ class QtScheduler(BaseScheduler):
|
|||||||
|
|
||||||
_timer = None
|
_timer = None
|
||||||
|
|
||||||
def start(self):
|
def shutdown(self, *args, **kwargs):
|
||||||
super(QtScheduler, self).start()
|
super(QtScheduler, self).shutdown(*args, **kwargs)
|
||||||
self.wakeup()
|
|
||||||
|
|
||||||
def shutdown(self, wait=True):
|
|
||||||
super(QtScheduler, self).shutdown(wait)
|
|
||||||
self._stop_timer()
|
self._stop_timer()
|
||||||
|
|
||||||
def _start_timer(self, wait_seconds):
|
def _start_timer(self, wait_seconds):
|
||||||
self._stop_timer()
|
self._stop_timer()
|
||||||
if wait_seconds is not None:
|
if wait_seconds is not None:
|
||||||
self._timer = QTimer.singleShot(wait_seconds * 1000, self._process_jobs)
|
wait_time = min(int(wait_seconds * 1000), 2147483647)
|
||||||
|
self._timer = QTimer.singleShot(wait_time, self._process_jobs)
|
||||||
|
|
||||||
def _stop_timer(self):
|
def _stop_timer(self):
|
||||||
if self._timer:
|
if self._timer:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
@@ -22,6 +23,8 @@ class TornadoScheduler(BaseScheduler):
|
|||||||
"""
|
"""
|
||||||
A scheduler that runs on a Tornado IOLoop.
|
A scheduler that runs on a Tornado IOLoop.
|
||||||
|
|
||||||
|
The default executor can run jobs based on native coroutines (``async def``).
|
||||||
|
|
||||||
=========== ===============================================================
|
=========== ===============================================================
|
||||||
``io_loop`` Tornado IOLoop instance to use (defaults to the global IO loop)
|
``io_loop`` Tornado IOLoop instance to use (defaults to the global IO loop)
|
||||||
=========== ===============================================================
|
=========== ===============================================================
|
||||||
@@ -30,10 +33,6 @@ class TornadoScheduler(BaseScheduler):
|
|||||||
_ioloop = None
|
_ioloop = None
|
||||||
_timeout = None
|
_timeout = None
|
||||||
|
|
||||||
def start(self):
|
|
||||||
super(TornadoScheduler, self).start()
|
|
||||||
self.wakeup()
|
|
||||||
|
|
||||||
@run_in_ioloop
|
@run_in_ioloop
|
||||||
def shutdown(self, wait=True):
|
def shutdown(self, wait=True):
|
||||||
super(TornadoScheduler, self).shutdown(wait)
|
super(TornadoScheduler, self).shutdown(wait)
|
||||||
@@ -53,6 +52,10 @@ class TornadoScheduler(BaseScheduler):
|
|||||||
self._ioloop.remove_timeout(self._timeout)
|
self._ioloop.remove_timeout(self._timeout)
|
||||||
del self._timeout
|
del self._timeout
|
||||||
|
|
||||||
|
def _create_default_executor(self):
|
||||||
|
from apscheduler.executors.tornado import TornadoExecutor
|
||||||
|
return TornadoExecutor()
|
||||||
|
|
||||||
@run_in_ioloop
|
@run_in_ioloop
|
||||||
def wakeup(self):
|
def wakeup(self):
|
||||||
self._stop_timer()
|
self._stop_timer()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
@@ -35,10 +36,6 @@ class TwistedScheduler(BaseScheduler):
|
|||||||
self._reactor = maybe_ref(config.pop('reactor', default_reactor))
|
self._reactor = maybe_ref(config.pop('reactor', default_reactor))
|
||||||
super(TwistedScheduler, self)._configure(config)
|
super(TwistedScheduler, self)._configure(config)
|
||||||
|
|
||||||
def start(self):
|
|
||||||
super(TwistedScheduler, self).start()
|
|
||||||
self.wakeup()
|
|
||||||
|
|
||||||
@run_in_reactor
|
@run_in_reactor
|
||||||
def shutdown(self, wait=True):
|
def shutdown(self, wait=True):
|
||||||
super(TwistedScheduler, self).shutdown(wait)
|
super(TwistedScheduler, self).shutdown(wait)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from abc import ABCMeta, abstractmethod
|
from abc import ABCMeta, abstractmethod
|
||||||
|
from datetime import timedelta
|
||||||
|
import random
|
||||||
|
|
||||||
import six
|
import six
|
||||||
|
|
||||||
@@ -6,11 +8,30 @@ import six
|
|||||||
class BaseTrigger(six.with_metaclass(ABCMeta)):
|
class BaseTrigger(six.with_metaclass(ABCMeta)):
|
||||||
"""Abstract base class that defines the interface that every trigger must implement."""
|
"""Abstract base class that defines the interface that every trigger must implement."""
|
||||||
|
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_next_fire_time(self, previous_fire_time, now):
|
def get_next_fire_time(self, previous_fire_time, now):
|
||||||
"""
|
"""
|
||||||
Returns the next datetime to fire on, If no such datetime can be calculated, returns ``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 previous_fire_time: the previous time the trigger was fired
|
||||||
:param datetime.datetime now: current datetime
|
:param datetime.datetime now: current datetime
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def _apply_jitter(self, next_fire_time, jitter, now):
|
||||||
|
"""
|
||||||
|
Randomize ``next_fire_time`` by adding a random value (the jitter).
|
||||||
|
|
||||||
|
:param datetime.datetime|None next_fire_time: next fire time without jitter applied. If
|
||||||
|
``None``, returns ``None``.
|
||||||
|
:param int|None jitter: maximum number of seconds to add to ``next_fire_time``
|
||||||
|
(if ``None`` or ``0``, returns ``next_fire_time``)
|
||||||
|
:param datetime.datetime now: current datetime
|
||||||
|
:return datetime.datetime|None: next fire time with a jitter.
|
||||||
|
"""
|
||||||
|
if next_fire_time is None or not jitter:
|
||||||
|
return next_fire_time
|
||||||
|
|
||||||
|
return next_fire_time + timedelta(seconds=random.uniform(0, jitter))
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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,17 +4,20 @@ from tzlocal import get_localzone
|
|||||||
import six
|
import six
|
||||||
|
|
||||||
from apscheduler.triggers.base import BaseTrigger
|
from apscheduler.triggers.base import BaseTrigger
|
||||||
from apscheduler.triggers.cron.fields import BaseField, WeekField, DayOfMonthField, DayOfWeekField, DEFAULT_VALUES
|
from apscheduler.triggers.cron.fields import (
|
||||||
from apscheduler.util import datetime_ceil, convert_to_datetime, datetime_repr, astimezone
|
BaseField, MonthField, WeekField, DayOfMonthField, DayOfWeekField, DEFAULT_VALUES)
|
||||||
|
from apscheduler.util import (
|
||||||
|
datetime_ceil, convert_to_datetime, datetime_repr, astimezone, localize, normalize)
|
||||||
|
|
||||||
|
|
||||||
class CronTrigger(BaseTrigger):
|
class CronTrigger(BaseTrigger):
|
||||||
"""
|
"""
|
||||||
Triggers when current time matches all specified time constraints, 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 year: 4-digit year
|
||||||
:param int|str month: month (1-12)
|
:param int|str month: month (1-12)
|
||||||
:param int|str day: day of the (1-31)
|
:param int|str day: day of month (1-31)
|
||||||
:param int|str week: ISO week (1-53)
|
:param int|str week: ISO week (1-53)
|
||||||
:param int|str day_of_week: number or name of weekday (0-6 or mon,tue,wed,thu,fri,sat,sun)
|
:param int|str day_of_week: number or name of weekday (0-6 or mon,tue,wed,thu,fri,sat,sun)
|
||||||
:param int|str hour: hour (0-23)
|
:param int|str hour: hour (0-23)
|
||||||
@@ -22,8 +25,9 @@ class CronTrigger(BaseTrigger):
|
|||||||
:param int|str second: second (0-59)
|
:param int|str second: second (0-59)
|
||||||
:param datetime|str start_date: earliest possible date/time to trigger on (inclusive)
|
:param datetime|str start_date: earliest possible date/time to trigger on (inclusive)
|
||||||
:param datetime|str end_date: latest possible date/time to trigger on (inclusive)
|
:param datetime|str end_date: latest possible date/time to trigger on (inclusive)
|
||||||
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations
|
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations (defaults
|
||||||
(defaults to scheduler timezone)
|
to scheduler timezone)
|
||||||
|
:param int|None jitter: delay the job execution by ``jitter`` seconds at most
|
||||||
|
|
||||||
.. note:: The first weekday is always **monday**.
|
.. note:: The first weekday is always **monday**.
|
||||||
"""
|
"""
|
||||||
@@ -31,7 +35,7 @@ class CronTrigger(BaseTrigger):
|
|||||||
FIELD_NAMES = ('year', 'month', 'day', 'week', 'day_of_week', 'hour', 'minute', 'second')
|
FIELD_NAMES = ('year', 'month', 'day', 'week', 'day_of_week', 'hour', 'minute', 'second')
|
||||||
FIELDS_MAP = {
|
FIELDS_MAP = {
|
||||||
'year': BaseField,
|
'year': BaseField,
|
||||||
'month': BaseField,
|
'month': MonthField,
|
||||||
'week': WeekField,
|
'week': WeekField,
|
||||||
'day': DayOfMonthField,
|
'day': DayOfMonthField,
|
||||||
'day_of_week': DayOfWeekField,
|
'day_of_week': DayOfWeekField,
|
||||||
@@ -40,15 +44,16 @@ class CronTrigger(BaseTrigger):
|
|||||||
'second': BaseField
|
'second': BaseField
|
||||||
}
|
}
|
||||||
|
|
||||||
__slots__ = 'timezone', 'start_date', 'end_date', 'fields'
|
__slots__ = 'timezone', 'start_date', 'end_date', 'fields', 'jitter'
|
||||||
|
|
||||||
def __init__(self, year=None, month=None, day=None, week=None, day_of_week=None, hour=None, minute=None,
|
def __init__(self, year=None, month=None, day=None, week=None, day_of_week=None, hour=None,
|
||||||
second=None, start_date=None, end_date=None, timezone=None):
|
minute=None, second=None, start_date=None, end_date=None, timezone=None,
|
||||||
|
jitter=None):
|
||||||
if timezone:
|
if timezone:
|
||||||
self.timezone = astimezone(timezone)
|
self.timezone = astimezone(timezone)
|
||||||
elif start_date and start_date.tzinfo:
|
elif isinstance(start_date, datetime) and start_date.tzinfo:
|
||||||
self.timezone = start_date.tzinfo
|
self.timezone = start_date.tzinfo
|
||||||
elif end_date and end_date.tzinfo:
|
elif isinstance(end_date, datetime) and end_date.tzinfo:
|
||||||
self.timezone = end_date.tzinfo
|
self.timezone = end_date.tzinfo
|
||||||
else:
|
else:
|
||||||
self.timezone = get_localzone()
|
self.timezone = get_localzone()
|
||||||
@@ -56,6 +61,8 @@ class CronTrigger(BaseTrigger):
|
|||||||
self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date')
|
self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date')
|
||||||
self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date')
|
self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date')
|
||||||
|
|
||||||
|
self.jitter = jitter
|
||||||
|
|
||||||
values = dict((key, value) for (key, value) in six.iteritems(locals())
|
values = dict((key, value) for (key, value) in six.iteritems(locals())
|
||||||
if key in self.FIELD_NAMES and value is not None)
|
if key in self.FIELD_NAMES and value is not None)
|
||||||
self.fields = []
|
self.fields = []
|
||||||
@@ -76,13 +83,35 @@ class CronTrigger(BaseTrigger):
|
|||||||
field = field_class(field_name, exprs, is_default)
|
field = field_class(field_name, exprs, is_default)
|
||||||
self.fields.append(field)
|
self.fields.append(field)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_crontab(cls, expr, timezone=None):
|
||||||
|
"""
|
||||||
|
Create a :class:`~CronTrigger` from a standard crontab expression.
|
||||||
|
|
||||||
|
See https://en.wikipedia.org/wiki/Cron for more information on the format accepted here.
|
||||||
|
|
||||||
|
:param expr: minute, hour, day of month, month, day of week
|
||||||
|
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations (
|
||||||
|
defaults to scheduler timezone)
|
||||||
|
:return: a :class:`~CronTrigger` instance
|
||||||
|
|
||||||
|
"""
|
||||||
|
values = expr.split()
|
||||||
|
if len(values) != 5:
|
||||||
|
raise ValueError('Wrong number of fields; got {}, expected 5'.format(len(values)))
|
||||||
|
|
||||||
|
return cls(minute=values[0], hour=values[1], day=values[2], month=values[3],
|
||||||
|
day_of_week=values[4], timezone=timezone)
|
||||||
|
|
||||||
def _increment_field_value(self, dateval, fieldnum):
|
def _increment_field_value(self, dateval, fieldnum):
|
||||||
"""
|
"""
|
||||||
Increments the designated field and resets all less significant fields to their minimum values.
|
Increments the designated field and resets all less significant fields to their minimum
|
||||||
|
values.
|
||||||
|
|
||||||
:type dateval: datetime
|
:type dateval: datetime
|
||||||
:type fieldnum: int
|
:type fieldnum: int
|
||||||
:return: a tuple containing the new date, and the number of the field that was actually incremented
|
:return: a tuple containing the new date, and the number of the field that was actually
|
||||||
|
incremented
|
||||||
:rtype: tuple
|
:rtype: tuple
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -115,7 +144,7 @@ class CronTrigger(BaseTrigger):
|
|||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
difference = datetime(**values) - dateval.replace(tzinfo=None)
|
difference = datetime(**values) - dateval.replace(tzinfo=None)
|
||||||
return self.timezone.normalize(dateval + difference), fieldnum
|
return normalize(dateval + difference), fieldnum
|
||||||
|
|
||||||
def _set_field_value(self, dateval, fieldnum, new_value):
|
def _set_field_value(self, dateval, fieldnum, new_value):
|
||||||
values = {}
|
values = {}
|
||||||
@@ -128,12 +157,13 @@ class CronTrigger(BaseTrigger):
|
|||||||
else:
|
else:
|
||||||
values[field.name] = new_value
|
values[field.name] = new_value
|
||||||
|
|
||||||
difference = datetime(**values) - dateval.replace(tzinfo=None)
|
return localize(datetime(**values), self.timezone)
|
||||||
return self.timezone.normalize(dateval + difference)
|
|
||||||
|
|
||||||
def get_next_fire_time(self, previous_fire_time, now):
|
def get_next_fire_time(self, previous_fire_time, now):
|
||||||
if previous_fire_time:
|
if previous_fire_time:
|
||||||
start_date = max(now, previous_fire_time + timedelta(microseconds=1))
|
start_date = min(now, previous_fire_time + timedelta(microseconds=1))
|
||||||
|
if start_date == previous_fire_time:
|
||||||
|
start_date += timedelta(microseconds=1)
|
||||||
else:
|
else:
|
||||||
start_date = max(now, self.start_date) if self.start_date else now
|
start_date = max(now, self.start_date) if self.start_date else now
|
||||||
|
|
||||||
@@ -163,7 +193,34 @@ class CronTrigger(BaseTrigger):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if fieldnum >= 0:
|
if fieldnum >= 0:
|
||||||
return next_date
|
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')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default]
|
options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default]
|
||||||
@@ -172,5 +229,11 @@ class CronTrigger(BaseTrigger):
|
|||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default]
|
options = ["%s='%s'" % (f.name, f) for f in self.fields if not f.is_default]
|
||||||
if self.start_date:
|
if self.start_date:
|
||||||
options.append("start_date='%s'" % datetime_repr(self.start_date))
|
options.append("start_date=%r" % datetime_repr(self.start_date))
|
||||||
return '<%s (%s)>' % (self.__class__.__name__, ', '.join(options))
|
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)
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
"""
|
"""This module contains the expressions applicable for CronTrigger's fields."""
|
||||||
This module contains the expressions applicable for CronTrigger's fields.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from calendar import monthrange
|
from calendar import monthrange
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from apscheduler.util import asint
|
from apscheduler.util import asint
|
||||||
|
|
||||||
__all__ = ('AllExpression', 'RangeExpression', 'WeekdayRangeExpression', 'WeekdayPositionExpression',
|
__all__ = ('AllExpression', 'RangeExpression', 'WeekdayRangeExpression',
|
||||||
'LastDayOfMonthExpression')
|
'WeekdayPositionExpression', 'LastDayOfMonthExpression')
|
||||||
|
|
||||||
|
|
||||||
WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||||
|
MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
|
||||||
|
|
||||||
|
|
||||||
class AllExpression(object):
|
class AllExpression(object):
|
||||||
@@ -22,6 +21,14 @@ class AllExpression(object):
|
|||||||
if self.step == 0:
|
if self.step == 0:
|
||||||
raise ValueError('Increment must be higher than 0')
|
raise ValueError('Increment must be higher than 0')
|
||||||
|
|
||||||
|
def validate_range(self, field_name):
|
||||||
|
from apscheduler.triggers.cron.fields import MIN_VALUES, MAX_VALUES
|
||||||
|
|
||||||
|
value_range = MAX_VALUES[field_name] - MIN_VALUES[field_name]
|
||||||
|
if self.step and self.step > value_range:
|
||||||
|
raise ValueError('the step value ({}) is higher than the total range of the '
|
||||||
|
'expression ({})'.format(self.step, value_range))
|
||||||
|
|
||||||
def get_next_value(self, date, field):
|
def get_next_value(self, date, field):
|
||||||
start = field.get_value(date)
|
start = field.get_value(date)
|
||||||
minval = field.get_min(date)
|
minval = field.get_min(date)
|
||||||
@@ -37,6 +44,9 @@ class AllExpression(object):
|
|||||||
if next <= maxval:
|
if next <= maxval:
|
||||||
return next
|
return next
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return isinstance(other, self.__class__) and self.step == other.step
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
if self.step:
|
if self.step:
|
||||||
return '*/%d' % self.step
|
return '*/%d' % self.step
|
||||||
@@ -51,7 +61,7 @@ class RangeExpression(AllExpression):
|
|||||||
r'(?P<first>\d+)(?:-(?P<last>\d+))?(?:/(?P<step>\d+))?$')
|
r'(?P<first>\d+)(?:-(?P<last>\d+))?(?:/(?P<step>\d+))?$')
|
||||||
|
|
||||||
def __init__(self, first, last=None, step=None):
|
def __init__(self, first, last=None, step=None):
|
||||||
AllExpression.__init__(self, step)
|
super(RangeExpression, self).__init__(step)
|
||||||
first = asint(first)
|
first = asint(first)
|
||||||
last = asint(last)
|
last = asint(last)
|
||||||
if last is None and step is None:
|
if last is None and step is None:
|
||||||
@@ -61,25 +71,41 @@ class RangeExpression(AllExpression):
|
|||||||
self.first = first
|
self.first = first
|
||||||
self.last = last
|
self.last = last
|
||||||
|
|
||||||
|
def validate_range(self, field_name):
|
||||||
|
from apscheduler.triggers.cron.fields import MIN_VALUES, MAX_VALUES
|
||||||
|
|
||||||
|
super(RangeExpression, self).validate_range(field_name)
|
||||||
|
if self.first < MIN_VALUES[field_name]:
|
||||||
|
raise ValueError('the first value ({}) is lower than the minimum value ({})'
|
||||||
|
.format(self.first, MIN_VALUES[field_name]))
|
||||||
|
if self.last is not None and self.last > MAX_VALUES[field_name]:
|
||||||
|
raise ValueError('the last value ({}) is higher than the maximum value ({})'
|
||||||
|
.format(self.last, MAX_VALUES[field_name]))
|
||||||
|
value_range = (self.last or MAX_VALUES[field_name]) - self.first
|
||||||
|
if self.step and self.step > value_range:
|
||||||
|
raise ValueError('the step value ({}) is higher than the total range of the '
|
||||||
|
'expression ({})'.format(self.step, value_range))
|
||||||
|
|
||||||
def get_next_value(self, date, field):
|
def get_next_value(self, date, field):
|
||||||
start = field.get_value(date)
|
startval = field.get_value(date)
|
||||||
minval = field.get_min(date)
|
minval = field.get_min(date)
|
||||||
maxval = field.get_max(date)
|
maxval = field.get_max(date)
|
||||||
|
|
||||||
# Apply range limits
|
# Apply range limits
|
||||||
minval = max(minval, self.first)
|
minval = max(minval, self.first)
|
||||||
if self.last is not None:
|
maxval = min(maxval, self.last) if self.last is not None else maxval
|
||||||
maxval = min(maxval, self.last)
|
nextval = max(minval, startval)
|
||||||
start = max(start, minval)
|
|
||||||
|
|
||||||
if not self.step:
|
# Apply the step if defined
|
||||||
next = start
|
if self.step:
|
||||||
else:
|
distance_to_next = (self.step - (nextval - minval)) % self.step
|
||||||
distance_to_next = (self.step - (start - minval)) % self.step
|
nextval += distance_to_next
|
||||||
next = start + distance_to_next
|
|
||||||
|
|
||||||
if next <= maxval:
|
return nextval if nextval <= maxval else None
|
||||||
return next
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return (isinstance(other, self.__class__) and self.first == other.first and
|
||||||
|
self.last == other.last)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
if self.last != self.first and self.last is not None:
|
if self.last != self.first and self.last is not None:
|
||||||
@@ -100,6 +126,37 @@ class RangeExpression(AllExpression):
|
|||||||
return "%s(%s)" % (self.__class__.__name__, ', '.join(args))
|
return "%s(%s)" % (self.__class__.__name__, ', '.join(args))
|
||||||
|
|
||||||
|
|
||||||
|
class MonthRangeExpression(RangeExpression):
|
||||||
|
value_re = re.compile(r'(?P<first>[a-z]+)(?:-(?P<last>[a-z]+))?', re.IGNORECASE)
|
||||||
|
|
||||||
|
def __init__(self, first, last=None):
|
||||||
|
try:
|
||||||
|
first_num = MONTHS.index(first.lower()) + 1
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError('Invalid month name "%s"' % first)
|
||||||
|
|
||||||
|
if last:
|
||||||
|
try:
|
||||||
|
last_num = MONTHS.index(last.lower()) + 1
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError('Invalid month name "%s"' % last)
|
||||||
|
else:
|
||||||
|
last_num = None
|
||||||
|
|
||||||
|
super(MonthRangeExpression, self).__init__(first_num, last_num)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.last != self.first and self.last is not None:
|
||||||
|
return '%s-%s' % (MONTHS[self.first - 1], MONTHS[self.last - 1])
|
||||||
|
return MONTHS[self.first - 1]
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
args = ["'%s'" % MONTHS[self.first]]
|
||||||
|
if self.last != self.first and self.last is not None:
|
||||||
|
args.append("'%s'" % MONTHS[self.last - 1])
|
||||||
|
return "%s(%s)" % (self.__class__.__name__, ', '.join(args))
|
||||||
|
|
||||||
|
|
||||||
class WeekdayRangeExpression(RangeExpression):
|
class WeekdayRangeExpression(RangeExpression):
|
||||||
value_re = re.compile(r'(?P<first>[a-z]+)(?:-(?P<last>[a-z]+))?', re.IGNORECASE)
|
value_re = re.compile(r'(?P<first>[a-z]+)(?:-(?P<last>[a-z]+))?', re.IGNORECASE)
|
||||||
|
|
||||||
@@ -117,7 +174,7 @@ class WeekdayRangeExpression(RangeExpression):
|
|||||||
else:
|
else:
|
||||||
last_num = None
|
last_num = None
|
||||||
|
|
||||||
RangeExpression.__init__(self, first_num, last_num)
|
super(WeekdayRangeExpression, self).__init__(first_num, last_num)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
if self.last != self.first and self.last is not None:
|
if self.last != self.first and self.last is not None:
|
||||||
@@ -133,9 +190,11 @@ class WeekdayRangeExpression(RangeExpression):
|
|||||||
|
|
||||||
class WeekdayPositionExpression(AllExpression):
|
class WeekdayPositionExpression(AllExpression):
|
||||||
options = ['1st', '2nd', '3rd', '4th', '5th', 'last']
|
options = ['1st', '2nd', '3rd', '4th', '5th', 'last']
|
||||||
value_re = re.compile(r'(?P<option_name>%s) +(?P<weekday_name>(?:\d+|\w+))' % '|'.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):
|
def __init__(self, option_name, weekday_name):
|
||||||
|
super(WeekdayPositionExpression, self).__init__(None)
|
||||||
try:
|
try:
|
||||||
self.option_num = self.options.index(option_name.lower())
|
self.option_num = self.options.index(option_name.lower())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -147,8 +206,7 @@ class WeekdayPositionExpression(AllExpression):
|
|||||||
raise ValueError('Invalid weekday name "%s"' % weekday_name)
|
raise ValueError('Invalid weekday name "%s"' % weekday_name)
|
||||||
|
|
||||||
def get_next_value(self, date, field):
|
def get_next_value(self, date, field):
|
||||||
# Figure out the weekday of the month's first day and the number
|
# Figure out the weekday of the month's first day and the number of days in that month
|
||||||
# of days in that month
|
|
||||||
first_day_wday, last_day = monthrange(date.year, date.month)
|
first_day_wday, last_day = monthrange(date.year, date.month)
|
||||||
|
|
||||||
# Calculate which day of the month is the first of the target weekdays
|
# Calculate which day of the month is the first of the target weekdays
|
||||||
@@ -160,23 +218,28 @@ class WeekdayPositionExpression(AllExpression):
|
|||||||
if self.option_num < 5:
|
if self.option_num < 5:
|
||||||
target_day = first_hit_day + self.option_num * 7
|
target_day = first_hit_day + self.option_num * 7
|
||||||
else:
|
else:
|
||||||
target_day = first_hit_day + ((last_day - first_hit_day) / 7) * 7
|
target_day = first_hit_day + ((last_day - first_hit_day) // 7) * 7
|
||||||
|
|
||||||
if target_day <= last_day and target_day >= date.day:
|
if target_day <= last_day and target_day >= date.day:
|
||||||
return target_day
|
return target_day
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return (super(WeekdayPositionExpression, self).__eq__(other) and
|
||||||
|
self.option_num == other.option_num and self.weekday == other.weekday)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '%s %s' % (self.options[self.option_num], WEEKDAYS[self.weekday])
|
return '%s %s' % (self.options[self.option_num], WEEKDAYS[self.weekday])
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "%s('%s', '%s')" % (self.__class__.__name__, self.options[self.option_num], WEEKDAYS[self.weekday])
|
return "%s('%s', '%s')" % (self.__class__.__name__, self.options[self.option_num],
|
||||||
|
WEEKDAYS[self.weekday])
|
||||||
|
|
||||||
|
|
||||||
class LastDayOfMonthExpression(AllExpression):
|
class LastDayOfMonthExpression(AllExpression):
|
||||||
value_re = re.compile(r'last', re.IGNORECASE)
|
value_re = re.compile(r'last', re.IGNORECASE)
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
pass
|
super(LastDayOfMonthExpression, self).__init__(None)
|
||||||
|
|
||||||
def get_next_value(self, date, field):
|
def get_next_value(self, date, field):
|
||||||
return monthrange(date.year, date.month)[1]
|
return monthrange(date.year, date.month)[1]
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
"""
|
"""Fields represent CronTrigger options which map to :class:`~datetime.datetime` fields."""
|
||||||
Fields represent CronTrigger options which map to :class:`~datetime.datetime`
|
|
||||||
fields.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from calendar import monthrange
|
from calendar import monthrange
|
||||||
|
import re
|
||||||
|
|
||||||
|
import six
|
||||||
|
|
||||||
from apscheduler.triggers.cron.expressions import (
|
from apscheduler.triggers.cron.expressions import (
|
||||||
AllExpression, RangeExpression, WeekdayPositionExpression, LastDayOfMonthExpression, WeekdayRangeExpression)
|
AllExpression, RangeExpression, WeekdayPositionExpression, LastDayOfMonthExpression,
|
||||||
|
WeekdayRangeExpression, MonthRangeExpression)
|
||||||
|
|
||||||
|
|
||||||
__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}
|
MIN_VALUES = {'year': 1970, 'month': 1, 'day': 1, 'week': 1, 'day_of_week': 0, 'hour': 0,
|
||||||
MAX_VALUES = {'year': 2 ** 63, 'month': 12, 'day:': 31, 'week': 53, 'day_of_week': 6, 'hour': 23, 'minute': 59,
|
'minute': 0, 'second': 0}
|
||||||
'second': 59}
|
MAX_VALUES = {'year': 9999, 'month': 12, 'day': 31, 'week': 53, 'day_of_week': 6, 'hour': 23,
|
||||||
DEFAULT_VALUES = {'year': '*', 'month': 1, 'day': 1, 'week': '*', 'day_of_week': '*', 'hour': 0, 'minute': 0,
|
'minute': 59, 'second': 59}
|
||||||
'second': 0}
|
DEFAULT_VALUES = {'year': '*', 'month': 1, 'day': 1, 'week': '*', 'day_of_week': '*', 'hour': 0,
|
||||||
|
'minute': 0, 'second': 0}
|
||||||
|
SEPARATOR = re.compile(' *, *')
|
||||||
|
|
||||||
|
|
||||||
class BaseField(object):
|
class BaseField(object):
|
||||||
@@ -50,23 +54,29 @@ class BaseField(object):
|
|||||||
self.expressions = []
|
self.expressions = []
|
||||||
|
|
||||||
# Split a comma-separated expression list, if any
|
# Split a comma-separated expression list, if any
|
||||||
exprs = str(exprs).strip()
|
for expr in SEPARATOR.split(str(exprs).strip()):
|
||||||
if ',' in exprs:
|
self.compile_expression(expr)
|
||||||
for expr in exprs.split(','):
|
|
||||||
self.compile_expression(expr)
|
|
||||||
else:
|
|
||||||
self.compile_expression(exprs)
|
|
||||||
|
|
||||||
def compile_expression(self, expr):
|
def compile_expression(self, expr):
|
||||||
for compiler in self.COMPILERS:
|
for compiler in self.COMPILERS:
|
||||||
match = compiler.value_re.match(expr)
|
match = compiler.value_re.match(expr)
|
||||||
if match:
|
if match:
|
||||||
compiled_expr = compiler(**match.groupdict())
|
compiled_expr = compiler(**match.groupdict())
|
||||||
|
|
||||||
|
try:
|
||||||
|
compiled_expr.validate_range(self.name)
|
||||||
|
except ValueError as e:
|
||||||
|
exc = ValueError('Error validating expression {!r}: {}'.format(expr, e))
|
||||||
|
six.raise_from(exc, None)
|
||||||
|
|
||||||
self.expressions.append(compiled_expr)
|
self.expressions.append(compiled_expr)
|
||||||
return
|
return
|
||||||
|
|
||||||
raise ValueError('Unrecognized expression "%s" for field "%s"' % (expr, self.name))
|
raise ValueError('Unrecognized expression "%s" for field "%s"' % (expr, self.name))
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return isinstance(self, self.__class__) and self.expressions == other.expressions
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
expr_strings = (str(e) for e in self.expressions)
|
expr_strings = (str(e) for e in self.expressions)
|
||||||
return ','.join(expr_strings)
|
return ','.join(expr_strings)
|
||||||
@@ -95,3 +105,7 @@ class DayOfWeekField(BaseField):
|
|||||||
|
|
||||||
def get_value(self, dateval):
|
def get_value(self, dateval):
|
||||||
return dateval.weekday()
|
return dateval.weekday()
|
||||||
|
|
||||||
|
|
||||||
|
class MonthField(BaseField):
|
||||||
|
COMPILERS = BaseField.COMPILERS + [MonthRangeExpression]
|
||||||
|
|||||||
@@ -14,15 +14,36 @@ class DateTrigger(BaseTrigger):
|
|||||||
:param datetime.tzinfo|str timezone: time zone for ``run_date`` if it doesn't have one already
|
:param datetime.tzinfo|str timezone: time zone for ``run_date`` if it doesn't have one already
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = 'timezone', 'run_date'
|
__slots__ = 'run_date'
|
||||||
|
|
||||||
def __init__(self, run_date=None, timezone=None):
|
def __init__(self, run_date=None, timezone=None):
|
||||||
timezone = astimezone(timezone) or get_localzone()
|
timezone = astimezone(timezone) or get_localzone()
|
||||||
self.run_date = convert_to_datetime(run_date or datetime.now(), timezone, 'run_date')
|
if run_date is not None:
|
||||||
|
self.run_date = convert_to_datetime(run_date, timezone, 'run_date')
|
||||||
|
else:
|
||||||
|
self.run_date = datetime.now(timezone)
|
||||||
|
|
||||||
def get_next_fire_time(self, previous_fire_time, now):
|
def get_next_fire_time(self, previous_fire_time, now):
|
||||||
return self.run_date if previous_fire_time is None else None
|
return self.run_date if previous_fire_time is None else None
|
||||||
|
|
||||||
|
def __getstate__(self):
|
||||||
|
return {
|
||||||
|
'version': 1,
|
||||||
|
'run_date': self.run_date
|
||||||
|
}
|
||||||
|
|
||||||
|
def __setstate__(self, state):
|
||||||
|
# This is for compatibility with APScheduler 3.0.x
|
||||||
|
if isinstance(state, tuple):
|
||||||
|
state = state[1]
|
||||||
|
|
||||||
|
if state.get('version', 1) > 1:
|
||||||
|
raise ValueError(
|
||||||
|
'Got serialized data for version %s of %s, but only version 1 can be handled' %
|
||||||
|
(state['version'], self.__class__.__name__))
|
||||||
|
|
||||||
|
self.run_date = state['run_date']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return 'date[%s]' % datetime_repr(self.run_date)
|
return 'date[%s]' % datetime_repr(self.run_date)
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ from math import ceil
|
|||||||
from tzlocal import get_localzone
|
from tzlocal import get_localzone
|
||||||
|
|
||||||
from apscheduler.triggers.base import BaseTrigger
|
from apscheduler.triggers.base import BaseTrigger
|
||||||
from apscheduler.util import convert_to_datetime, timedelta_seconds, datetime_repr, astimezone
|
from apscheduler.util import (
|
||||||
|
convert_to_datetime, normalize, timedelta_seconds, datetime_repr,
|
||||||
|
astimezone)
|
||||||
|
|
||||||
|
|
||||||
class IntervalTrigger(BaseTrigger):
|
class IntervalTrigger(BaseTrigger):
|
||||||
"""
|
"""
|
||||||
Triggers on specified intervals, starting on ``start_date`` if specified, ``datetime.now()`` + interval
|
Triggers on specified intervals, starting on ``start_date`` if specified, ``datetime.now()`` +
|
||||||
otherwise.
|
interval otherwise.
|
||||||
|
|
||||||
:param int weeks: number of weeks to wait
|
:param int weeks: number of weeks to wait
|
||||||
:param int days: number of days to wait
|
:param int days: number of days to wait
|
||||||
@@ -20,12 +22,15 @@ class IntervalTrigger(BaseTrigger):
|
|||||||
:param datetime|str start_date: starting point for the interval calculation
|
:param datetime|str start_date: starting point for the interval calculation
|
||||||
:param datetime|str end_date: latest possible date/time to trigger on
|
:param datetime|str end_date: latest possible date/time to trigger on
|
||||||
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations
|
:param datetime.tzinfo|str timezone: time zone to use for the date/time calculations
|
||||||
|
:param int|None jitter: delay the job execution by ``jitter`` seconds at most
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = 'timezone', 'start_date', 'end_date', 'interval'
|
__slots__ = 'timezone', 'start_date', 'end_date', 'interval', 'interval_length', 'jitter'
|
||||||
|
|
||||||
def __init__(self, weeks=0, days=0, hours=0, minutes=0, seconds=0, start_date=None, end_date=None, timezone=None):
|
def __init__(self, weeks=0, days=0, hours=0, minutes=0, seconds=0, start_date=None,
|
||||||
self.interval = timedelta(weeks=weeks, days=days, hours=hours, minutes=minutes, seconds=seconds)
|
end_date=None, timezone=None, jitter=None):
|
||||||
|
self.interval = timedelta(weeks=weeks, days=days, hours=hours, minutes=minutes,
|
||||||
|
seconds=seconds)
|
||||||
self.interval_length = timedelta_seconds(self.interval)
|
self.interval_length = timedelta_seconds(self.interval)
|
||||||
if self.interval_length == 0:
|
if self.interval_length == 0:
|
||||||
self.interval = timedelta(seconds=1)
|
self.interval = timedelta(seconds=1)
|
||||||
@@ -33,9 +38,9 @@ class IntervalTrigger(BaseTrigger):
|
|||||||
|
|
||||||
if timezone:
|
if timezone:
|
||||||
self.timezone = astimezone(timezone)
|
self.timezone = astimezone(timezone)
|
||||||
elif start_date and start_date.tzinfo:
|
elif isinstance(start_date, datetime) and start_date.tzinfo:
|
||||||
self.timezone = start_date.tzinfo
|
self.timezone = start_date.tzinfo
|
||||||
elif end_date and end_date.tzinfo:
|
elif isinstance(end_date, datetime) and end_date.tzinfo:
|
||||||
self.timezone = end_date.tzinfo
|
self.timezone = end_date.tzinfo
|
||||||
else:
|
else:
|
||||||
self.timezone = get_localzone()
|
self.timezone = get_localzone()
|
||||||
@@ -44,6 +49,8 @@ class IntervalTrigger(BaseTrigger):
|
|||||||
self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date')
|
self.start_date = convert_to_datetime(start_date, self.timezone, 'start_date')
|
||||||
self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date')
|
self.end_date = convert_to_datetime(end_date, self.timezone, 'end_date')
|
||||||
|
|
||||||
|
self.jitter = jitter
|
||||||
|
|
||||||
def get_next_fire_time(self, previous_fire_time, now):
|
def get_next_fire_time(self, previous_fire_time, now):
|
||||||
if previous_fire_time:
|
if previous_fire_time:
|
||||||
next_fire_time = previous_fire_time + self.interval
|
next_fire_time = previous_fire_time + self.interval
|
||||||
@@ -54,12 +61,48 @@ class IntervalTrigger(BaseTrigger):
|
|||||||
next_interval_num = int(ceil(timediff_seconds / self.interval_length))
|
next_interval_num = int(ceil(timediff_seconds / self.interval_length))
|
||||||
next_fire_time = self.start_date + self.interval * next_interval_num
|
next_fire_time = self.start_date + self.interval * next_interval_num
|
||||||
|
|
||||||
|
if self.jitter is not None:
|
||||||
|
next_fire_time = self._apply_jitter(next_fire_time, self.jitter, now)
|
||||||
|
|
||||||
if not self.end_date or next_fire_time <= self.end_date:
|
if not self.end_date or next_fire_time <= self.end_date:
|
||||||
return self.timezone.normalize(next_fire_time)
|
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')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return 'interval[%s]' % str(self.interval)
|
return 'interval[%s]' % str(self.interval)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<%s (interval=%r, start_date='%s')>" % (self.__class__.__name__, self.interval,
|
options = ['interval=%r' % self.interval, 'start_date=%r' % datetime_repr(self.start_date)]
|
||||||
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)
|
||||||
|
|||||||
+156
-111
@@ -1,29 +1,36 @@
|
|||||||
"""This module contains several handy functions primarily meant for internal use."""
|
"""This module contains several handy functions primarily meant for internal use."""
|
||||||
|
|
||||||
|
from __future__ import division
|
||||||
|
|
||||||
|
from asyncio import iscoroutinefunction
|
||||||
from datetime import date, datetime, time, timedelta, tzinfo
|
from datetime import date, datetime, time, timedelta, tzinfo
|
||||||
from inspect import isfunction, ismethod, getargspec
|
|
||||||
from calendar import timegm
|
from calendar import timegm
|
||||||
|
from functools import partial
|
||||||
|
from inspect import isclass, ismethod
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
from pytz import timezone, utc
|
from pytz import timezone, utc, FixedOffset
|
||||||
import six
|
import six
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from inspect import signature
|
from inspect import signature
|
||||||
except ImportError: # pragma: nocover
|
except ImportError: # pragma: nocover
|
||||||
try:
|
from funcsigs import signature
|
||||||
from funcsigs import signature
|
|
||||||
except ImportError:
|
try:
|
||||||
signature = None
|
from threading import TIMEOUT_MAX
|
||||||
|
except ImportError:
|
||||||
|
TIMEOUT_MAX = 4294967 # Maximum value accepted by Event.wait() on Windows
|
||||||
|
|
||||||
__all__ = ('asint', 'asbool', 'astimezone', 'convert_to_datetime', 'datetime_to_utc_timestamp',
|
__all__ = ('asint', 'asbool', 'astimezone', 'convert_to_datetime', 'datetime_to_utc_timestamp',
|
||||||
'utc_timestamp_to_datetime', 'timedelta_seconds', 'datetime_ceil', 'get_callable_name', 'obj_to_ref',
|
'utc_timestamp_to_datetime', 'timedelta_seconds', 'datetime_ceil', 'get_callable_name',
|
||||||
'ref_to_obj', 'maybe_ref', 'repr_escape', 'check_callable_args')
|
'obj_to_ref', 'ref_to_obj', 'maybe_ref', 'repr_escape', 'check_callable_args',
|
||||||
|
'normalize', 'localize', 'TIMEOUT_MAX')
|
||||||
|
|
||||||
|
|
||||||
class _Undefined(object):
|
class _Undefined(object):
|
||||||
def __bool__(self):
|
def __nonzero__(self):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def __bool__(self):
|
def __bool__(self):
|
||||||
@@ -32,17 +39,18 @@ class _Undefined(object):
|
|||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return '<undefined>'
|
return '<undefined>'
|
||||||
|
|
||||||
|
|
||||||
undefined = _Undefined() #: a unique object that only signifies that no value is defined
|
undefined = _Undefined() #: a unique object that only signifies that no value is defined
|
||||||
|
|
||||||
|
|
||||||
def asint(text):
|
def asint(text):
|
||||||
"""
|
"""
|
||||||
Safely converts a string to an integer, returning None if the string is None.
|
Safely converts a string to an integer, returning ``None`` if the string is ``None``.
|
||||||
|
|
||||||
:type text: str
|
:type text: str
|
||||||
:rtype: int
|
:rtype: int
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
if text is not None:
|
if text is not None:
|
||||||
return int(text)
|
return int(text)
|
||||||
|
|
||||||
@@ -52,8 +60,8 @@ def asbool(obj):
|
|||||||
Interprets an object as a boolean value.
|
Interprets an object as a boolean value.
|
||||||
|
|
||||||
:rtype: bool
|
:rtype: bool
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
if isinstance(obj, str):
|
if isinstance(obj, str):
|
||||||
obj = obj.strip().lower()
|
obj = obj.strip().lower()
|
||||||
if obj in ('true', 'yes', 'on', 'y', 't', '1'):
|
if obj in ('true', 'yes', 'on', 'y', 't', '1'):
|
||||||
@@ -69,15 +77,17 @@ def astimezone(obj):
|
|||||||
Interprets an object as a timezone.
|
Interprets an object as a timezone.
|
||||||
|
|
||||||
:rtype: tzinfo
|
:rtype: tzinfo
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
if isinstance(obj, six.string_types):
|
if isinstance(obj, six.string_types):
|
||||||
return timezone(obj)
|
return timezone(obj)
|
||||||
if isinstance(obj, tzinfo):
|
if isinstance(obj, tzinfo):
|
||||||
if not hasattr(obj, 'localize') or not hasattr(obj, 'normalize'):
|
if obj.tzname(None) == 'local':
|
||||||
raise TypeError('Only timezones from the pytz library are supported')
|
raise ValueError(
|
||||||
if obj.zone == 'local':
|
'Unable to determine the name of the local timezone -- you must explicitly '
|
||||||
raise ValueError('Unable to determine the name of the local timezone -- use an explicit timezone instead')
|
'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).')
|
||||||
return obj
|
return obj
|
||||||
if obj is not None:
|
if obj is not None:
|
||||||
raise TypeError('Expected tzinfo, got %s instead' % obj.__class__.__name__)
|
raise TypeError('Expected tzinfo, got %s instead' % obj.__class__.__name__)
|
||||||
@@ -85,27 +95,30 @@ def astimezone(obj):
|
|||||||
|
|
||||||
_DATE_REGEX = re.compile(
|
_DATE_REGEX = re.compile(
|
||||||
r'(?P<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})'
|
r'(?P<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})'
|
||||||
r'(?: (?P<hour>\d{1,2}):(?P<minute>\d{1,2}):(?P<second>\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<microsecond>\d{1,6}))?'
|
||||||
|
r'(?P<timezone>Z|[+-]\d\d:\d\d)?)?$')
|
||||||
|
|
||||||
|
|
||||||
def convert_to_datetime(input, tz, arg_name):
|
def convert_to_datetime(input, tz, arg_name):
|
||||||
"""
|
"""
|
||||||
Converts the given object to a timezone aware datetime object.
|
Converts the given object to a timezone aware datetime object.
|
||||||
|
|
||||||
If a timezone aware datetime object is passed, it is returned unmodified.
|
If a timezone aware datetime object is passed, it is returned unmodified.
|
||||||
If a native datetime object is passed, it is given the specified timezone.
|
If a native datetime object is passed, it is given the specified timezone.
|
||||||
If the input is a string, it is parsed as a datetime with the given timezone.
|
If the input is a string, it is parsed as a datetime with the given timezone.
|
||||||
|
|
||||||
Date strings are accepted in three different forms: date only (Y-m-d),
|
Date strings are accepted in three different forms: date only (Y-m-d), date with time
|
||||||
date with time (Y-m-d H:M:S) or with date+time with microseconds
|
(Y-m-d H:M:S) or with date+time with microseconds (Y-m-d H:M:S.micro). Additionally you can
|
||||||
(Y-m-d H:M:S.micro).
|
override the time zone by giving a specific offset in the format specified by ISO 8601:
|
||||||
|
Z (UTC), +HH:MM or -HH:MM.
|
||||||
|
|
||||||
:param str|datetime input: the datetime or string to convert to a timezone aware datetime
|
:param str|datetime input: the datetime or string to convert to a timezone aware datetime
|
||||||
:param datetime.tzinfo tz: timezone to interpret ``input`` in
|
:param datetime.tzinfo tz: timezone to interpret ``input`` in
|
||||||
:param str arg_name: the name of the argument (used in an error message)
|
:param str arg_name: the name of the argument (used in an error message)
|
||||||
:rtype: datetime
|
:rtype: datetime
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
if input is None:
|
if input is None:
|
||||||
return
|
return
|
||||||
elif isinstance(input, datetime):
|
elif isinstance(input, datetime):
|
||||||
@@ -116,8 +129,17 @@ def convert_to_datetime(input, tz, arg_name):
|
|||||||
m = _DATE_REGEX.match(input)
|
m = _DATE_REGEX.match(input)
|
||||||
if not m:
|
if not m:
|
||||||
raise ValueError('Invalid date string')
|
raise ValueError('Invalid date string')
|
||||||
values = [(k, int(v or 0)) for k, v in list(m.groupdict().items())]
|
|
||||||
values = dict(values)
|
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()}
|
||||||
datetime_ = datetime(**values)
|
datetime_ = datetime(**values)
|
||||||
else:
|
else:
|
||||||
raise TypeError('Unsupported type for %s: %s' % (arg_name, input.__class__.__name__))
|
raise TypeError('Unsupported type for %s: %s' % (arg_name, input.__class__.__name__))
|
||||||
@@ -125,14 +147,12 @@ def convert_to_datetime(input, tz, arg_name):
|
|||||||
if datetime_.tzinfo is not None:
|
if datetime_.tzinfo is not None:
|
||||||
return datetime_
|
return datetime_
|
||||||
if tz is None:
|
if tz is None:
|
||||||
raise ValueError('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):
|
if isinstance(tz, six.string_types):
|
||||||
tz = timezone(tz)
|
tz = timezone(tz)
|
||||||
|
|
||||||
try:
|
return localize(datetime_, tz)
|
||||||
return tz.localize(datetime_, is_dst=None)
|
|
||||||
except AttributeError:
|
|
||||||
raise TypeError('Only pytz timezones are supported (need the localize() and normalize() methods)')
|
|
||||||
|
|
||||||
|
|
||||||
def datetime_to_utc_timestamp(timeval):
|
def datetime_to_utc_timestamp(timeval):
|
||||||
@@ -141,8 +161,8 @@ def datetime_to_utc_timestamp(timeval):
|
|||||||
|
|
||||||
:type timeval: datetime
|
:type timeval: datetime
|
||||||
:rtype: float
|
:rtype: float
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
if timeval is not None:
|
if timeval is not None:
|
||||||
return timegm(timeval.utctimetuple()) + timeval.microsecond / 1000000
|
return timegm(timeval.utctimetuple()) + timeval.microsecond / 1000000
|
||||||
|
|
||||||
@@ -153,8 +173,8 @@ def utc_timestamp_to_datetime(timestamp):
|
|||||||
|
|
||||||
:type timestamp: float
|
:type timestamp: float
|
||||||
:rtype: datetime
|
:rtype: datetime
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
if timestamp is not None:
|
if timestamp is not None:
|
||||||
return datetime.fromtimestamp(timestamp, utc)
|
return datetime.fromtimestamp(timestamp, utc)
|
||||||
|
|
||||||
@@ -165,8 +185,8 @@ def timedelta_seconds(delta):
|
|||||||
|
|
||||||
:type delta: timedelta
|
:type delta: timedelta
|
||||||
:rtype: float
|
:rtype: float
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
return delta.days * 24 * 60 * 60 + delta.seconds + \
|
return delta.days * 24 * 60 * 60 + delta.seconds + \
|
||||||
delta.microseconds / 1000000.0
|
delta.microseconds / 1000000.0
|
||||||
|
|
||||||
@@ -176,8 +196,8 @@ def datetime_ceil(dateval):
|
|||||||
Rounds the given datetime object upwards.
|
Rounds the given datetime object upwards.
|
||||||
|
|
||||||
:type dateval: datetime
|
:type dateval: datetime
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
if dateval.microsecond > 0:
|
if dateval.microsecond > 0:
|
||||||
return dateval + timedelta(seconds=1, microseconds=-dateval.microsecond)
|
return dateval + timedelta(seconds=1, microseconds=-dateval.microsecond)
|
||||||
return dateval
|
return dateval
|
||||||
@@ -192,8 +212,8 @@ def get_callable_name(func):
|
|||||||
Returns the best available display name for the given function/callable.
|
Returns the best available display name for the given function/callable.
|
||||||
|
|
||||||
:rtype: str
|
:rtype: str
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
# the easy case (on Python 3.3+)
|
# the easy case (on Python 3.3+)
|
||||||
if hasattr(func, '__qualname__'):
|
if hasattr(func, '__qualname__'):
|
||||||
return func.__qualname__
|
return func.__qualname__
|
||||||
@@ -201,7 +221,7 @@ def get_callable_name(func):
|
|||||||
# class methods, bound and unbound methods
|
# class methods, bound and unbound methods
|
||||||
f_self = getattr(func, '__self__', None) or getattr(func, 'im_self', None)
|
f_self = getattr(func, '__self__', None) or getattr(func, 'im_self', None)
|
||||||
if f_self and hasattr(func, '__name__'):
|
if f_self and hasattr(func, '__name__'):
|
||||||
f_class = f_self if isinstance(f_self, type) else f_self.__class__
|
f_class = f_self if isclass(f_self) else f_self.__class__
|
||||||
else:
|
else:
|
||||||
f_class = getattr(func, 'im_class', None)
|
f_class = getattr(func, 'im_class', None)
|
||||||
|
|
||||||
@@ -222,20 +242,35 @@ def get_callable_name(func):
|
|||||||
|
|
||||||
def obj_to_ref(obj):
|
def obj_to_ref(obj):
|
||||||
"""
|
"""
|
||||||
Returns the path to the given object.
|
Returns the path to the given callable.
|
||||||
|
|
||||||
:rtype: str
|
:rtype: str
|
||||||
|
:raises TypeError: if the given object is not callable
|
||||||
|
:raises ValueError: if the given object is a :class:`~functools.partial`, lambda or a nested
|
||||||
|
function
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
if isinstance(obj, partial):
|
||||||
|
raise ValueError('Cannot create a reference to a partial()')
|
||||||
|
|
||||||
try:
|
name = get_callable_name(obj)
|
||||||
ref = '%s:%s' % (obj.__module__, get_callable_name(obj))
|
if '<lambda>' in name:
|
||||||
obj2 = ref_to_obj(ref)
|
raise ValueError('Cannot create a reference to a lambda')
|
||||||
if obj != obj2:
|
if '<locals>' in name:
|
||||||
raise ValueError
|
raise ValueError('Cannot create a reference to a nested function')
|
||||||
except Exception:
|
|
||||||
raise ValueError('Cannot determine the reference to %r' % obj)
|
|
||||||
|
|
||||||
return ref
|
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)
|
||||||
|
|
||||||
|
|
||||||
def ref_to_obj(ref):
|
def ref_to_obj(ref):
|
||||||
@@ -243,8 +278,8 @@ def ref_to_obj(ref):
|
|||||||
Returns the object pointed to by ``ref``.
|
Returns the object pointed to by ``ref``.
|
||||||
|
|
||||||
:type ref: str
|
:type ref: str
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
if not isinstance(ref, six.string_types):
|
if not isinstance(ref, six.string_types):
|
||||||
raise TypeError('References must be strings')
|
raise TypeError('References must be strings')
|
||||||
if ':' not in ref:
|
if ':' not in ref:
|
||||||
@@ -252,12 +287,12 @@ def ref_to_obj(ref):
|
|||||||
|
|
||||||
modulename, rest = ref.split(':', 1)
|
modulename, rest = ref.split(':', 1)
|
||||||
try:
|
try:
|
||||||
obj = __import__(modulename)
|
obj = __import__(modulename, fromlist=[rest])
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise LookupError('Error resolving reference %s: could not import module' % ref)
|
raise LookupError('Error resolving reference %s: could not import module' % ref)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for name in modulename.split('.')[1:] + rest.split('.'):
|
for name in rest.split('.'):
|
||||||
obj = getattr(obj, name)
|
obj = getattr(obj, name)
|
||||||
return obj
|
return obj
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -268,8 +303,8 @@ def maybe_ref(ref):
|
|||||||
"""
|
"""
|
||||||
Returns the object that the given reference points to, if it is indeed a reference.
|
Returns the object that the given reference points to, if it is indeed a reference.
|
||||||
If it is not a reference, the object is returned as-is.
|
If it is not a reference, the object is returned as-is.
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
if not isinstance(ref, str):
|
if not isinstance(ref, str):
|
||||||
return ref
|
return ref
|
||||||
return ref_to_obj(ref)
|
return ref_to_obj(ref)
|
||||||
@@ -281,7 +316,8 @@ if six.PY2:
|
|||||||
return string.encode('ascii', 'backslashreplace')
|
return string.encode('ascii', 'backslashreplace')
|
||||||
return string
|
return string
|
||||||
else:
|
else:
|
||||||
repr_escape = lambda string: string
|
def repr_escape(string):
|
||||||
|
return string
|
||||||
|
|
||||||
|
|
||||||
def check_callable_args(func, args, kwargs):
|
def check_callable_args(func, args, kwargs):
|
||||||
@@ -290,70 +326,54 @@ def check_callable_args(func, args, kwargs):
|
|||||||
|
|
||||||
:type args: tuple
|
:type args: tuple
|
||||||
:type kwargs: dict
|
:type kwargs: dict
|
||||||
"""
|
|
||||||
|
|
||||||
|
"""
|
||||||
pos_kwargs_conflicts = [] # parameters that have a match in both args and kwargs
|
pos_kwargs_conflicts = [] # parameters that have a match in both args and kwargs
|
||||||
positional_only_kwargs = [] # positional-only parameters that have a match in kwargs
|
positional_only_kwargs = [] # positional-only parameters that have a match in kwargs
|
||||||
unsatisfied_args = [] # parameters in signature that don't have a match in args or kwargs
|
unsatisfied_args = [] # parameters in signature that don't have a match in args or kwargs
|
||||||
unsatisfied_kwargs = [] # keyword-only arguments that don't have a match in kwargs
|
unsatisfied_kwargs = [] # keyword-only arguments that don't have a match in kwargs
|
||||||
unmatched_args = list(args) # args that didn't match any of the parameters in the signature
|
unmatched_args = list(args) # args that didn't match any of the parameters in the signature
|
||||||
unmatched_kwargs = list(kwargs) # kwargs that didn't match any of the parameters in the signature
|
# 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
|
unmatched_kwargs = list(kwargs)
|
||||||
|
# indicates if the signature defines *args and **kwargs respectively
|
||||||
|
has_varargs = has_var_kwargs = False
|
||||||
|
|
||||||
if signature:
|
try:
|
||||||
try:
|
if sys.version_info >= (3, 5):
|
||||||
|
sig = signature(func, follow_wrapped=False)
|
||||||
|
else:
|
||||||
sig = signature(func)
|
sig = signature(func)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return # signature() doesn't work against every kind of callable
|
# signature() doesn't work against every kind of callable
|
||||||
|
return
|
||||||
|
|
||||||
for param in six.itervalues(sig.parameters):
|
for param in six.itervalues(sig.parameters):
|
||||||
if param.kind == param.POSITIONAL_OR_KEYWORD:
|
if param.kind == param.POSITIONAL_OR_KEYWORD:
|
||||||
if param.name in unmatched_kwargs and unmatched_args:
|
if param.name in unmatched_kwargs and unmatched_args:
|
||||||
pos_kwargs_conflicts.append(param.name)
|
pos_kwargs_conflicts.append(param.name)
|
||||||
elif unmatched_args:
|
|
||||||
del unmatched_args[0]
|
|
||||||
elif param.name in unmatched_kwargs:
|
|
||||||
unmatched_kwargs.remove(param.name)
|
|
||||||
elif param.default is param.empty:
|
|
||||||
unsatisfied_args.append(param.name)
|
|
||||||
elif param.kind == param.POSITIONAL_ONLY:
|
|
||||||
if unmatched_args:
|
|
||||||
del unmatched_args[0]
|
|
||||||
elif param.name in unmatched_kwargs:
|
|
||||||
unmatched_kwargs.remove(param.name)
|
|
||||||
positional_only_kwargs.append(param.name)
|
|
||||||
elif param.default is param.empty:
|
|
||||||
unsatisfied_args.append(param.name)
|
|
||||||
elif param.kind == param.KEYWORD_ONLY:
|
|
||||||
if param.name in unmatched_kwargs:
|
|
||||||
unmatched_kwargs.remove(param.name)
|
|
||||||
elif param.default is param.empty:
|
|
||||||
unsatisfied_kwargs.append(param.name)
|
|
||||||
elif param.kind == param.VAR_POSITIONAL:
|
|
||||||
has_varargs = True
|
|
||||||
elif param.kind == param.VAR_KEYWORD:
|
|
||||||
has_var_kwargs = True
|
|
||||||
else:
|
|
||||||
if not isfunction(func) and not ismethod(func) and hasattr(func, '__call__'):
|
|
||||||
func = func.__call__
|
|
||||||
|
|
||||||
try:
|
|
||||||
argspec = getargspec(func)
|
|
||||||
except TypeError:
|
|
||||||
return # getargspec() doesn't work certain callables
|
|
||||||
|
|
||||||
argspec_args = argspec.args if not ismethod(func) else argspec.args[1:]
|
|
||||||
has_varargs = bool(argspec.varargs)
|
|
||||||
has_var_kwargs = bool(argspec.keywords)
|
|
||||||
for arg, default in six.moves.zip_longest(argspec_args, argspec.defaults or (), fillvalue=undefined):
|
|
||||||
if arg in unmatched_kwargs and unmatched_args:
|
|
||||||
pos_kwargs_conflicts.append(arg)
|
|
||||||
elif unmatched_args:
|
elif unmatched_args:
|
||||||
del unmatched_args[0]
|
del unmatched_args[0]
|
||||||
elif arg in unmatched_kwargs:
|
elif param.name in unmatched_kwargs:
|
||||||
unmatched_kwargs.remove(arg)
|
unmatched_kwargs.remove(param.name)
|
||||||
elif default is undefined:
|
elif param.default is param.empty:
|
||||||
unsatisfied_args.append(arg)
|
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
|
||||||
|
|
||||||
# Make sure there are no conflicts between args and kwargs
|
# Make sure there are no conflicts between args and kwargs
|
||||||
if pos_kwargs_conflicts:
|
if pos_kwargs_conflicts:
|
||||||
@@ -365,21 +385,46 @@ def check_callable_args(func, args, kwargs):
|
|||||||
raise ValueError('The following arguments cannot be given as keyword arguments: %s' %
|
raise ValueError('The following arguments cannot be given as keyword arguments: %s' %
|
||||||
', '.join(positional_only_kwargs))
|
', '.join(positional_only_kwargs))
|
||||||
|
|
||||||
# Check that the number of positional arguments minus the number of matched kwargs matches the argspec
|
# Check that the number of positional arguments minus the number of matched kwargs matches the
|
||||||
|
# argspec
|
||||||
if unsatisfied_args:
|
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
|
# Check that all keyword-only arguments have been supplied
|
||||||
if unsatisfied_kwargs:
|
if unsatisfied_kwargs:
|
||||||
raise ValueError('The following keyword-only arguments have not been supplied in kwargs: %s' %
|
raise ValueError(
|
||||||
', '.join(unsatisfied_kwargs))
|
'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
|
# Check that the callable can accept the given number of positional arguments
|
||||||
if not has_varargs and unmatched_args:
|
if not has_varargs and unmatched_args:
|
||||||
raise ValueError('The list of positional arguments is longer than the target callable can handle '
|
raise ValueError(
|
||||||
'(allowed: %d, given in args: %d)' % (len(args) - len(unmatched_args), len(args)))
|
'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
|
# Check that the callable can accept the given keyword arguments
|
||||||
if not has_var_kwargs and unmatched_kwargs:
|
if not has_var_kwargs and unmatched_kwargs:
|
||||||
raise ValueError('The target callable does not accept the following keyword arguments: %s' %
|
raise ValueError(
|
||||||
', '.join(unmatched_kwargs))
|
'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))
|
||||||
|
|||||||
@@ -13,28 +13,29 @@
|
|||||||
# included in all copies or substantial portions of the Software.
|
# included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
|
||||||
import confuse
|
|
||||||
from sys import stderr
|
from sys import stderr
|
||||||
|
|
||||||
__version__ = '1.6.0'
|
import confuse
|
||||||
__author__ = 'Adrian Sampson <adrian@radbox.org>'
|
|
||||||
|
__version__ = "2.0.0"
|
||||||
|
__author__ = "Adrian Sampson <adrian@radbox.org>"
|
||||||
|
|
||||||
|
|
||||||
class IncludeLazyConfig(confuse.LazyConfig):
|
class IncludeLazyConfig(confuse.LazyConfig):
|
||||||
"""A version of Confuse's LazyConfig that also merges in data from
|
"""A version of Confuse's LazyConfig that also merges in data from
|
||||||
YAML files specified in an `include` setting.
|
YAML files specified in an `include` setting.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def read(self, user=True, defaults=True):
|
def read(self, user=True, defaults=True):
|
||||||
super().read(user, defaults)
|
super().read(user, defaults)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for view in self['include']:
|
for view in self["include"]:
|
||||||
self.set_file(view.as_filename())
|
self.set_file(view.as_filename())
|
||||||
except confuse.NotFoundError:
|
except confuse.NotFoundError:
|
||||||
pass
|
pass
|
||||||
except confuse.ConfigReadError as err:
|
except confuse.ConfigReadError as err:
|
||||||
stderr.write("configuration `import` failed: {}"
|
stderr.write("configuration `import` failed: {}".format(err.reason))
|
||||||
.format(err.reason))
|
|
||||||
|
|
||||||
|
|
||||||
config = IncludeLazyConfig('beets', __name__)
|
config = IncludeLazyConfig("beets", __name__)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from .ui import main
|
from .ui import main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+108
-112
@@ -17,21 +17,19 @@ music and items' embedded album art.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import platform
|
|
||||||
from tempfile import NamedTemporaryFile
|
|
||||||
import os
|
import os
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
|
||||||
from beets.util import displayable_path, syspath, bytestring_path
|
|
||||||
from beets.util.artresizer import ArtResizer
|
|
||||||
import mediafile
|
import mediafile
|
||||||
|
|
||||||
|
from beets.util import bytestring_path, displayable_path, syspath
|
||||||
|
from beets.util.artresizer import ArtResizer
|
||||||
|
|
||||||
|
|
||||||
def mediafile_image(image_path, maxwidth=None):
|
def mediafile_image(image_path, maxwidth=None):
|
||||||
"""Return a `mediafile.Image` object for the path.
|
"""Return a `mediafile.Image` object for the path."""
|
||||||
"""
|
|
||||||
|
|
||||||
with open(syspath(image_path), 'rb') as f:
|
with open(syspath(image_path), "rb") as f:
|
||||||
data = f.read()
|
data = f.read()
|
||||||
return mediafile.Image(data, type=mediafile.ImageType.front)
|
return mediafile.Image(data, type=mediafile.ImageType.front)
|
||||||
|
|
||||||
@@ -41,170 +39,168 @@ def get_art(log, item):
|
|||||||
try:
|
try:
|
||||||
mf = mediafile.MediaFile(syspath(item.path))
|
mf = mediafile.MediaFile(syspath(item.path))
|
||||||
except mediafile.UnreadableFileError as exc:
|
except mediafile.UnreadableFileError as exc:
|
||||||
log.warning('Could not extract art from {0}: {1}',
|
log.warning(
|
||||||
displayable_path(item.path), exc)
|
"Could not extract art from {0}: {1}",
|
||||||
|
displayable_path(item.path),
|
||||||
|
exc,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
return mf.art
|
return mf.art
|
||||||
|
|
||||||
|
|
||||||
def embed_item(log, item, imagepath, maxwidth=None, itempath=None,
|
def embed_item(
|
||||||
compare_threshold=0, ifempty=False, as_album=False, id3v23=None,
|
log,
|
||||||
quality=0):
|
item,
|
||||||
"""Embed an image into the item's media file.
|
imagepath,
|
||||||
"""
|
maxwidth=None,
|
||||||
# Conditions and filters.
|
itempath=None,
|
||||||
|
compare_threshold=0,
|
||||||
|
ifempty=False,
|
||||||
|
as_album=False,
|
||||||
|
id3v23=None,
|
||||||
|
quality=0,
|
||||||
|
):
|
||||||
|
"""Embed an image into the item's media file."""
|
||||||
|
# Conditions.
|
||||||
if compare_threshold:
|
if compare_threshold:
|
||||||
if not check_art_similarity(log, item, imagepath, compare_threshold):
|
is_similar = check_art_similarity(
|
||||||
log.info('Image not similar; skipping.')
|
log, item, imagepath, compare_threshold
|
||||||
|
)
|
||||||
|
if is_similar is None:
|
||||||
|
log.warning("Error while checking art similarity; skipping.")
|
||||||
return
|
return
|
||||||
|
elif not is_similar:
|
||||||
|
log.info("Image not similar; skipping.")
|
||||||
|
return
|
||||||
|
|
||||||
if ifempty and get_art(log, item):
|
if ifempty and get_art(log, item):
|
||||||
log.info('media file already contained art')
|
log.info("media file already contained art")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Filters.
|
||||||
if maxwidth and not as_album:
|
if maxwidth and not as_album:
|
||||||
imagepath = resize_image(log, imagepath, maxwidth, quality)
|
imagepath = resize_image(log, imagepath, maxwidth, quality)
|
||||||
|
|
||||||
# Get the `Image` object from the file.
|
# Get the `Image` object from the file.
|
||||||
try:
|
try:
|
||||||
log.debug('embedding {0}', displayable_path(imagepath))
|
log.debug("embedding {0}", displayable_path(imagepath))
|
||||||
image = mediafile_image(imagepath, maxwidth)
|
image = mediafile_image(imagepath, maxwidth)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
log.warning('could not read image file: {0}', exc)
|
log.warning("could not read image file: {0}", exc)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Make sure the image kind is safe (some formats only support PNG
|
# Make sure the image kind is safe (some formats only support PNG
|
||||||
# and JPEG).
|
# and JPEG).
|
||||||
if image.mime_type not in ('image/jpeg', 'image/png'):
|
if image.mime_type not in ("image/jpeg", "image/png"):
|
||||||
log.info('not embedding image of unsupported type: {}',
|
log.info("not embedding image of unsupported type: {}", image.mime_type)
|
||||||
image.mime_type)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
item.try_write(path=itempath, tags={'images': [image]}, id3v23=id3v23)
|
item.try_write(path=itempath, tags={"images": [image]}, id3v23=id3v23)
|
||||||
|
|
||||||
|
|
||||||
def embed_album(log, album, maxwidth=None, quiet=False, compare_threshold=0,
|
def embed_album(
|
||||||
ifempty=False, quality=0):
|
log,
|
||||||
"""Embed album art into all of the album's items.
|
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
|
imagepath = album.artpath
|
||||||
if not imagepath:
|
if not imagepath:
|
||||||
log.info('No album art present for {0}', album)
|
log.info("No album art present for {0}", album)
|
||||||
return
|
return
|
||||||
if not os.path.isfile(syspath(imagepath)):
|
if not os.path.isfile(syspath(imagepath)):
|
||||||
log.info('Album art not found at {0} for {1}',
|
log.info(
|
||||||
displayable_path(imagepath), album)
|
"Album art not found at {0} for {1}",
|
||||||
|
displayable_path(imagepath),
|
||||||
|
album,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
if maxwidth:
|
if maxwidth:
|
||||||
imagepath = resize_image(log, imagepath, maxwidth, quality)
|
imagepath = resize_image(log, imagepath, maxwidth, quality)
|
||||||
|
|
||||||
log.info('Embedding album art into {0}', album)
|
log.info("Embedding album art into {0}", album)
|
||||||
|
|
||||||
for item in album.items():
|
for item in album.items():
|
||||||
embed_item(log, item, imagepath, maxwidth, None, compare_threshold,
|
embed_item(
|
||||||
ifempty, as_album=True, quality=quality)
|
log,
|
||||||
|
item,
|
||||||
|
imagepath,
|
||||||
|
maxwidth,
|
||||||
|
None,
|
||||||
|
compare_threshold,
|
||||||
|
ifempty,
|
||||||
|
as_album=True,
|
||||||
|
quality=quality,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def resize_image(log, imagepath, maxwidth, quality):
|
def resize_image(log, imagepath, maxwidth, quality):
|
||||||
"""Returns path to an image resized to maxwidth and encoded with the
|
"""Returns path to an image resized to maxwidth and encoded with the
|
||||||
specified quality level.
|
specified quality level.
|
||||||
"""
|
"""
|
||||||
log.debug('Resizing album art to {0} pixels wide and encoding at quality \
|
log.debug(
|
||||||
level {1}', maxwidth, quality)
|
"Resizing album art to {0} pixels wide and encoding at quality \
|
||||||
imagepath = ArtResizer.shared.resize(maxwidth, syspath(imagepath),
|
level {1}",
|
||||||
quality=quality)
|
maxwidth,
|
||||||
|
quality,
|
||||||
|
)
|
||||||
|
imagepath = ArtResizer.shared.resize(
|
||||||
|
maxwidth, syspath(imagepath), quality=quality
|
||||||
|
)
|
||||||
return imagepath
|
return imagepath
|
||||||
|
|
||||||
|
|
||||||
def check_art_similarity(log, item, imagepath, compare_threshold):
|
def check_art_similarity(
|
||||||
|
log,
|
||||||
|
item,
|
||||||
|
imagepath,
|
||||||
|
compare_threshold,
|
||||||
|
artresizer=None,
|
||||||
|
):
|
||||||
"""A boolean indicating if an image is similar to embedded item art.
|
"""A boolean indicating if an image is similar to embedded item art.
|
||||||
|
|
||||||
|
If no embedded art exists, always return `True`. If the comparison fails
|
||||||
|
for some reason, the return value is `None`.
|
||||||
|
|
||||||
|
This must only be called if `ArtResizer.shared.can_compare` is `True`.
|
||||||
"""
|
"""
|
||||||
with NamedTemporaryFile(delete=True) as f:
|
with NamedTemporaryFile(delete=True) as f:
|
||||||
art = extract(log, f.name, item)
|
art = extract(log, f.name, item)
|
||||||
|
|
||||||
if art:
|
if not art:
|
||||||
is_windows = platform.system() == "Windows"
|
return True
|
||||||
|
|
||||||
# Converting images to grayscale tends to minimize the weight
|
if artresizer is None:
|
||||||
# of colors in the diff score. So we first convert both images
|
artresizer = ArtResizer.shared
|
||||||
# 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check the convert output. We're not interested in the
|
return artresizer.compare(art, imagepath, compare_threshold)
|
||||||
# standard output; that gets piped to the next stage.
|
|
||||||
convert_proc.stdout.close()
|
|
||||||
convert_stderr = convert_proc.stderr.read()
|
|
||||||
convert_proc.stderr.close()
|
|
||||||
convert_proc.wait()
|
|
||||||
if convert_proc.returncode:
|
|
||||||
log.debug(
|
|
||||||
'ImageMagick convert failed with status {}: {!r}',
|
|
||||||
convert_proc.returncode,
|
|
||||||
convert_stderr,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check the compare output.
|
|
||||||
stdout, stderr = compare_proc.communicate()
|
|
||||||
if compare_proc.returncode:
|
|
||||||
if compare_proc.returncode != 1:
|
|
||||||
log.debug('ImageMagick compare failed: {0}, {1}',
|
|
||||||
displayable_path(imagepath),
|
|
||||||
displayable_path(art))
|
|
||||||
return
|
|
||||||
out_str = stderr
|
|
||||||
else:
|
|
||||||
out_str = stdout
|
|
||||||
|
|
||||||
try:
|
|
||||||
phash_diff = float(out_str)
|
|
||||||
except ValueError:
|
|
||||||
log.debug('IM output is not a number: {0!r}', out_str)
|
|
||||||
return
|
|
||||||
|
|
||||||
log.debug('ImageMagick compare score: {0}', phash_diff)
|
|
||||||
return phash_diff <= compare_threshold
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def extract(log, outpath, item):
|
def extract(log, outpath, item):
|
||||||
art = get_art(log, item)
|
art = get_art(log, item)
|
||||||
outpath = bytestring_path(outpath)
|
outpath = bytestring_path(outpath)
|
||||||
if not art:
|
if not art:
|
||||||
log.info('No album art present in {0}, skipping.', item)
|
log.info("No album art present in {0}, skipping.", item)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Add an extension to the filename.
|
# Add an extension to the filename.
|
||||||
ext = mediafile.image_extension(art)
|
ext = mediafile.image_extension(art)
|
||||||
if not ext:
|
if not ext:
|
||||||
log.warning('Unknown image type in {0}.',
|
log.warning("Unknown image type in {0}.", displayable_path(item.path))
|
||||||
displayable_path(item.path))
|
|
||||||
return
|
return
|
||||||
outpath += bytestring_path('.' + ext)
|
outpath += bytestring_path("." + ext)
|
||||||
|
|
||||||
log.info('Extracting album art from: {0} to: {1}',
|
log.info(
|
||||||
item, displayable_path(outpath))
|
"Extracting album art from: {0} to: {1}",
|
||||||
with open(syspath(outpath), 'wb') as f:
|
item,
|
||||||
|
displayable_path(outpath),
|
||||||
|
)
|
||||||
|
with open(syspath(outpath), "wb") as f:
|
||||||
f.write(art)
|
f.write(art)
|
||||||
return outpath
|
return outpath
|
||||||
|
|
||||||
@@ -218,7 +214,7 @@ def extract_first(log, outpath, items):
|
|||||||
|
|
||||||
def clear(log, lib, query):
|
def clear(log, lib, query):
|
||||||
items = lib.items(query)
|
items = lib.items(query)
|
||||||
log.info('Clearing album art from {0} items', len(items))
|
log.info("Clearing album art from {0} items", len(items))
|
||||||
for item in items:
|
for item in items:
|
||||||
log.debug('Clearing art for {0}', item)
|
log.debug("Clearing art for {0}", item)
|
||||||
item.try_write(tags={'images': None})
|
item.try_write(tags={"images": None})
|
||||||
|
|||||||
@@ -14,78 +14,91 @@
|
|||||||
|
|
||||||
"""Facilities for automatically determining files' correct metadata.
|
"""Facilities for automatically determining files' correct metadata.
|
||||||
"""
|
"""
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
from beets import config, logging
|
||||||
from beets import logging
|
from beets.library import Item
|
||||||
from beets import config
|
|
||||||
|
|
||||||
# Parts of external interface.
|
# Parts of external interface.
|
||||||
from .hooks import ( # noqa
|
from .hooks import ( # noqa
|
||||||
AlbumInfo,
|
AlbumInfo,
|
||||||
TrackInfo,
|
|
||||||
AlbumMatch,
|
AlbumMatch,
|
||||||
TrackMatch,
|
|
||||||
Distance,
|
Distance,
|
||||||
|
TrackInfo,
|
||||||
|
TrackMatch,
|
||||||
)
|
)
|
||||||
from .match import tag_item, tag_album, Proposal # noqa
|
|
||||||
from .match import Recommendation # noqa
|
from .match import Recommendation # noqa
|
||||||
|
from .match import Proposal, current_metadata, tag_album, tag_item # noqa
|
||||||
|
|
||||||
# Global logger.
|
# Global logger.
|
||||||
log = logging.getLogger('beets')
|
log = logging.getLogger("beets")
|
||||||
|
|
||||||
# Metadata fields that are already hardcoded, or where the tag name changes.
|
# Metadata fields that are already hardcoded, or where the tag name changes.
|
||||||
SPECIAL_FIELDS = {
|
SPECIAL_FIELDS = {
|
||||||
'album': (
|
"album": (
|
||||||
'va',
|
"va",
|
||||||
'releasegroup_id',
|
"releasegroup_id",
|
||||||
'artist_id',
|
"artist_id",
|
||||||
'album_id',
|
"artists_ids",
|
||||||
'mediums',
|
"album_id",
|
||||||
'tracks',
|
"mediums",
|
||||||
'year',
|
"tracks",
|
||||||
'month',
|
"year",
|
||||||
'day',
|
"month",
|
||||||
'artist',
|
"day",
|
||||||
'artist_credit',
|
"artist",
|
||||||
'artist_sort',
|
"artists",
|
||||||
'data_url'
|
"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",
|
||||||
),
|
),
|
||||||
'track': (
|
|
||||||
'track_alt',
|
|
||||||
'artist_id',
|
|
||||||
'release_track_id',
|
|
||||||
'medium',
|
|
||||||
'index',
|
|
||||||
'medium_index',
|
|
||||||
'title',
|
|
||||||
'artist_credit',
|
|
||||||
'artist_sort',
|
|
||||||
'artist',
|
|
||||||
'track_id',
|
|
||||||
'medium_total',
|
|
||||||
'data_url',
|
|
||||||
'length'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Additional utilities for the main interface.
|
# Additional utilities for the main interface.
|
||||||
|
|
||||||
def apply_item_metadata(item, track_info):
|
|
||||||
"""Set an item's metadata from its matched TrackInfo object.
|
def apply_item_metadata(item: Item, track_info: TrackInfo):
|
||||||
"""
|
"""Set an item's metadata from its matched TrackInfo object."""
|
||||||
item.artist = track_info.artist
|
item.artist = track_info.artist
|
||||||
|
item.artists = track_info.artists
|
||||||
item.artist_sort = track_info.artist_sort
|
item.artist_sort = track_info.artist_sort
|
||||||
|
item.artists_sort = track_info.artists_sort
|
||||||
item.artist_credit = track_info.artist_credit
|
item.artist_credit = track_info.artist_credit
|
||||||
|
item.artists_credit = track_info.artists_credit
|
||||||
item.title = track_info.title
|
item.title = track_info.title
|
||||||
item.mb_trackid = track_info.track_id
|
item.mb_trackid = track_info.track_id
|
||||||
item.mb_releasetrackid = track_info.release_track_id
|
item.mb_releasetrackid = track_info.release_track_id
|
||||||
if track_info.artist_id:
|
if track_info.artist_id:
|
||||||
item.mb_artistid = track_info.artist_id
|
item.mb_artistid = track_info.artist_id
|
||||||
|
if track_info.artists_ids:
|
||||||
|
item.mb_artistids = track_info.artists_ids
|
||||||
|
|
||||||
for field, value in track_info.items():
|
for field, value in track_info.items():
|
||||||
# We only overwrite fields that are not already hardcoded.
|
# We only overwrite fields that are not already hardcoded.
|
||||||
if field in SPECIAL_FIELDS['track']:
|
if field in SPECIAL_FIELDS["track"]:
|
||||||
continue
|
continue
|
||||||
if value is None:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
@@ -95,45 +108,62 @@ def apply_item_metadata(item, track_info):
|
|||||||
# and track number). Perhaps these should be emptied?
|
# and track number). Perhaps these should be emptied?
|
||||||
|
|
||||||
|
|
||||||
def apply_metadata(album_info, mapping):
|
def apply_metadata(album_info: AlbumInfo, mapping: Mapping[Item, TrackInfo]):
|
||||||
"""Set the items' metadata to match an AlbumInfo object using a
|
"""Set the items' metadata to match an AlbumInfo object using a
|
||||||
mapping from Items to TrackInfo objects.
|
mapping from Items to TrackInfo objects.
|
||||||
"""
|
"""
|
||||||
for item, track_info in mapping.items():
|
for item, track_info in mapping.items():
|
||||||
# Artist or artist credit.
|
# Artist or artist credit.
|
||||||
if config['artist_credit']:
|
if config["artist_credit"]:
|
||||||
item.artist = (track_info.artist_credit or
|
item.artist = (
|
||||||
track_info.artist or
|
track_info.artist_credit
|
||||||
album_info.artist_credit or
|
or track_info.artist
|
||||||
album_info.artist)
|
or album_info.artist_credit
|
||||||
item.albumartist = (album_info.artist_credit or
|
or album_info.artist
|
||||||
album_info.artist)
|
)
|
||||||
|
item.artists = (
|
||||||
|
track_info.artists_credit
|
||||||
|
or track_info.artists
|
||||||
|
or album_info.artists_credit
|
||||||
|
or album_info.artists
|
||||||
|
)
|
||||||
|
item.albumartist = album_info.artist_credit or album_info.artist
|
||||||
|
item.albumartists = album_info.artists_credit or album_info.artists
|
||||||
else:
|
else:
|
||||||
item.artist = (track_info.artist or album_info.artist)
|
item.artist = track_info.artist or album_info.artist
|
||||||
|
item.artists = track_info.artists or album_info.artists
|
||||||
item.albumartist = album_info.artist
|
item.albumartist = album_info.artist
|
||||||
|
item.albumartists = album_info.artists
|
||||||
|
|
||||||
# Album.
|
# Album.
|
||||||
item.album = album_info.album
|
item.album = album_info.album
|
||||||
|
|
||||||
# Artist sort and credit names.
|
# Artist sort and credit names.
|
||||||
item.artist_sort = track_info.artist_sort or album_info.artist_sort
|
item.artist_sort = track_info.artist_sort or album_info.artist_sort
|
||||||
item.artist_credit = (track_info.artist_credit or
|
item.artists_sort = track_info.artists_sort or album_info.artists_sort
|
||||||
album_info.artist_credit)
|
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.albumartist_sort = album_info.artist_sort
|
item.albumartist_sort = album_info.artist_sort
|
||||||
|
item.albumartists_sort = album_info.artists_sort
|
||||||
item.albumartist_credit = album_info.artist_credit
|
item.albumartist_credit = album_info.artist_credit
|
||||||
|
item.albumartists_credit = album_info.artists_credit
|
||||||
|
|
||||||
# Release date.
|
# Release date.
|
||||||
for prefix in '', 'original_':
|
for prefix in "", "original_":
|
||||||
if config['original_date'] and not prefix:
|
if config["original_date"] and not prefix:
|
||||||
# Ignore specific release date.
|
# Ignore specific release date.
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for suffix in 'year', 'month', 'day':
|
for suffix in "year", "month", "day":
|
||||||
key = prefix + suffix
|
key = prefix + suffix
|
||||||
value = getattr(album_info, key) or 0
|
value = getattr(album_info, key) or 0
|
||||||
|
|
||||||
# If we don't even have a year, apply nothing.
|
# If we don't even have a year, apply nothing.
|
||||||
if suffix == 'year' and not value:
|
if suffix == "year" and not value:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Otherwise, set the fetched value (or 0 for the month
|
# Otherwise, set the fetched value (or 0 for the month
|
||||||
@@ -142,13 +172,13 @@ def apply_metadata(album_info, mapping):
|
|||||||
|
|
||||||
# If we're using original release date for both fields,
|
# If we're using original release date for both fields,
|
||||||
# also set item.year = info.original_year, etc.
|
# also set item.year = info.original_year, etc.
|
||||||
if config['original_date']:
|
if config["original_date"]:
|
||||||
item[suffix] = value
|
item[suffix] = value
|
||||||
|
|
||||||
# Title.
|
# Title.
|
||||||
item.title = track_info.title
|
item.title = track_info.title
|
||||||
|
|
||||||
if config['per_disc_numbering']:
|
if config["per_disc_numbering"]:
|
||||||
# We want to let the track number be zero, but if the medium index
|
# We want to let the track number be zero, but if the medium index
|
||||||
# is not provided we need to fall back to the overall index.
|
# is not provided we need to fall back to the overall index.
|
||||||
if track_info.medium_index is not None:
|
if track_info.medium_index is not None:
|
||||||
@@ -172,7 +202,14 @@ def apply_metadata(album_info, mapping):
|
|||||||
item.mb_artistid = track_info.artist_id
|
item.mb_artistid = track_info.artist_id
|
||||||
else:
|
else:
|
||||||
item.mb_artistid = album_info.artist_id
|
item.mb_artistid = album_info.artist_id
|
||||||
|
|
||||||
|
if track_info.artists_ids:
|
||||||
|
item.mb_artistids = track_info.artists_ids
|
||||||
|
else:
|
||||||
|
item.mb_artistids = album_info.artists_ids
|
||||||
|
|
||||||
item.mb_albumartistid = album_info.artist_id
|
item.mb_albumartistid = album_info.artist_id
|
||||||
|
item.mb_albumartistids = album_info.artists_ids
|
||||||
item.mb_releasegroupid = album_info.releasegroup_id
|
item.mb_releasegroupid = album_info.releasegroup_id
|
||||||
|
|
||||||
# Compilation flag.
|
# Compilation flag.
|
||||||
@@ -184,17 +221,17 @@ def apply_metadata(album_info, mapping):
|
|||||||
# Don't overwrite fields with empty values unless the
|
# Don't overwrite fields with empty values unless the
|
||||||
# field is explicitly allowed to be overwritten
|
# field is explicitly allowed to be overwritten
|
||||||
for field, value in album_info.items():
|
for field, value in album_info.items():
|
||||||
if field in SPECIAL_FIELDS['album']:
|
if field in SPECIAL_FIELDS["album"]:
|
||||||
continue
|
continue
|
||||||
clobber = field in config['overwrite_null']['album'].as_str_seq()
|
clobber = field in config["overwrite_null"]["album"].as_str_seq()
|
||||||
if value is None and not clobber:
|
if value is None and not clobber:
|
||||||
continue
|
continue
|
||||||
item[field] = value
|
item[field] = value
|
||||||
|
|
||||||
for field, value in track_info.items():
|
for field, value in track_info.items():
|
||||||
if field in SPECIAL_FIELDS['track']:
|
if field in SPECIAL_FIELDS["track"]:
|
||||||
continue
|
continue
|
||||||
clobber = field in config['overwrite_null']['track'].as_str_seq()
|
clobber = field in config["overwrite_null"]["track"].as_str_seq()
|
||||||
value = getattr(track_info, field)
|
value = getattr(track_info, field)
|
||||||
if value is None and not clobber:
|
if value is None and not clobber:
|
||||||
continue
|
continue
|
||||||
|
|||||||
+242
-184
@@ -14,40 +14,51 @@
|
|||||||
|
|
||||||
"""Glue between metadata sources and the matching logic."""
|
"""Glue between metadata sources and the matching logic."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from functools import total_ordering
|
from functools import total_ordering
|
||||||
import re
|
from typing import (
|
||||||
|
Any,
|
||||||
|
Callable,
|
||||||
|
Dict,
|
||||||
|
Iterable,
|
||||||
|
Iterator,
|
||||||
|
List,
|
||||||
|
Optional,
|
||||||
|
Tuple,
|
||||||
|
TypeVar,
|
||||||
|
Union,
|
||||||
|
cast,
|
||||||
|
)
|
||||||
|
|
||||||
from beets import logging
|
|
||||||
from beets import plugins
|
|
||||||
from beets import config
|
|
||||||
from beets.util import as_string
|
|
||||||
from beets.autotag import mb
|
|
||||||
from jellyfish import levenshtein_distance
|
from jellyfish import levenshtein_distance
|
||||||
from unidecode import unidecode
|
from unidecode import unidecode
|
||||||
|
|
||||||
log = logging.getLogger('beets')
|
from beets import config, logging, plugins
|
||||||
|
from beets.autotag import mb
|
||||||
|
from beets.library import Item
|
||||||
|
from beets.util import as_string, cached_classproperty
|
||||||
|
|
||||||
# The name of the type for patterns in re changed in Python 3.7.
|
log = logging.getLogger("beets")
|
||||||
try:
|
|
||||||
Pattern = re._pattern_type
|
V = TypeVar("V")
|
||||||
except AttributeError:
|
|
||||||
Pattern = re.Pattern
|
|
||||||
|
|
||||||
|
|
||||||
# Classes used to represent candidate options.
|
# Classes used to represent candidate options.
|
||||||
class AttrDict(dict):
|
class AttrDict(Dict[str, V]):
|
||||||
"""A dictionary that supports attribute ("dot") access, so `d.field`
|
"""A dictionary that supports attribute ("dot") access, so `d.field`
|
||||||
is equivalent to `d['field']`.
|
is equivalent to `d['field']`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __getattr__(self, attr):
|
def __getattr__(self, attr: str) -> V:
|
||||||
if attr in self:
|
if attr in self:
|
||||||
return self.get(attr)
|
return self[attr]
|
||||||
else:
|
else:
|
||||||
raise AttributeError
|
raise AttributeError
|
||||||
|
|
||||||
def __setattr__(self, key, value):
|
def __setattr__(self, key: str, value: V):
|
||||||
self.__setitem__(key, value)
|
self.__setitem__(key, value)
|
||||||
|
|
||||||
def __hash__(self):
|
def __hash__(self):
|
||||||
@@ -68,32 +79,73 @@ class AlbumInfo(AttrDict):
|
|||||||
The others are optional and may be None.
|
The others are optional and may be None.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, tracks, album=None, album_id=None, artist=None,
|
# TYPING: are all of these correct? I've assumed optional strings
|
||||||
artist_id=None, asin=None, albumtype=None, va=False,
|
def __init__(
|
||||||
year=None, month=None, day=None, label=None, mediums=None,
|
self,
|
||||||
artist_sort=None, releasegroup_id=None, catalognum=None,
|
tracks: List[TrackInfo],
|
||||||
script=None, language=None, country=None, style=None,
|
album: Optional[str] = None,
|
||||||
genre=None, albumstatus=None, media=None, albumdisambig=None,
|
album_id: Optional[str] = None,
|
||||||
releasegroupdisambig=None, artist_credit=None,
|
artist: Optional[str] = None,
|
||||||
original_year=None, original_month=None,
|
artist_id: Optional[str] = None,
|
||||||
original_day=None, data_source=None, data_url=None,
|
artists: Optional[List[str]] = None,
|
||||||
discogs_albumid=None, discogs_labelid=None,
|
artists_ids: Optional[List[str]] = None,
|
||||||
discogs_artistid=None, **kwargs):
|
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,
|
||||||
|
):
|
||||||
self.album = album
|
self.album = album
|
||||||
self.album_id = album_id
|
self.album_id = album_id
|
||||||
self.artist = artist
|
self.artist = artist
|
||||||
self.artist_id = artist_id
|
self.artist_id = artist_id
|
||||||
|
self.artists = artists or []
|
||||||
|
self.artists_ids = artists_ids or []
|
||||||
self.tracks = tracks
|
self.tracks = tracks
|
||||||
self.asin = asin
|
self.asin = asin
|
||||||
self.albumtype = albumtype
|
self.albumtype = albumtype
|
||||||
|
self.albumtypes = albumtypes or []
|
||||||
self.va = va
|
self.va = va
|
||||||
self.year = year
|
self.year = year
|
||||||
self.month = month
|
self.month = month
|
||||||
self.day = day
|
self.day = day
|
||||||
self.label = label
|
self.label = label
|
||||||
|
self.barcode = barcode
|
||||||
self.mediums = mediums
|
self.mediums = mediums
|
||||||
self.artist_sort = artist_sort
|
self.artist_sort = artist_sort
|
||||||
|
self.artists_sort = artists_sort or []
|
||||||
self.releasegroup_id = releasegroup_id
|
self.releasegroup_id = releasegroup_id
|
||||||
|
self.release_group_title = release_group_title
|
||||||
self.catalognum = catalognum
|
self.catalognum = catalognum
|
||||||
self.script = script
|
self.script = script
|
||||||
self.language = language
|
self.language = language
|
||||||
@@ -105,6 +157,7 @@ class AlbumInfo(AttrDict):
|
|||||||
self.albumdisambig = albumdisambig
|
self.albumdisambig = albumdisambig
|
||||||
self.releasegroupdisambig = releasegroupdisambig
|
self.releasegroupdisambig = releasegroupdisambig
|
||||||
self.artist_credit = artist_credit
|
self.artist_credit = artist_credit
|
||||||
|
self.artists_credit = artists_credit or []
|
||||||
self.original_year = original_year
|
self.original_year = original_year
|
||||||
self.original_month = original_month
|
self.original_month = original_month
|
||||||
self.original_day = original_day
|
self.original_day = original_day
|
||||||
@@ -115,27 +168,7 @@ class AlbumInfo(AttrDict):
|
|||||||
self.discogs_artistid = discogs_artistid
|
self.discogs_artistid = discogs_artistid
|
||||||
self.update(kwargs)
|
self.update(kwargs)
|
||||||
|
|
||||||
# Work around a bug in python-musicbrainz-ngs that causes some
|
def copy(self) -> AlbumInfo:
|
||||||
# strings to be bytes rather than Unicode.
|
|
||||||
# https://github.com/alastair/python-musicbrainz-ngs/issues/85
|
|
||||||
def decode(self, codec='utf-8'):
|
|
||||||
"""Ensure that all string attributes on this object, and the
|
|
||||||
constituent `TrackInfo` objects, are decoded to Unicode.
|
|
||||||
"""
|
|
||||||
for fld in ['album', 'artist', 'albumtype', 'label', 'artist_sort',
|
|
||||||
'catalognum', 'script', 'language', 'country', 'style',
|
|
||||||
'genre', 'albumstatus', 'albumdisambig',
|
|
||||||
'releasegroupdisambig', 'artist_credit',
|
|
||||||
'media', 'discogs_albumid', 'discogs_labelid',
|
|
||||||
'discogs_artistid']:
|
|
||||||
value = getattr(self, fld)
|
|
||||||
if isinstance(value, bytes):
|
|
||||||
setattr(self, fld, value.decode(codec, 'ignore'))
|
|
||||||
|
|
||||||
for track in self.tracks:
|
|
||||||
track.decode(codec)
|
|
||||||
|
|
||||||
def copy(self):
|
|
||||||
dupe = AlbumInfo([])
|
dupe = AlbumInfo([])
|
||||||
dupe.update(self)
|
dupe.update(self)
|
||||||
dupe.tracks = [track.copy() for track in self.tracks]
|
dupe.tracks = [track.copy() for track in self.tracks]
|
||||||
@@ -154,20 +187,50 @@ class TrackInfo(AttrDict):
|
|||||||
are all 1-based.
|
are all 1-based.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, title=None, track_id=None, release_track_id=None,
|
# TYPING: are all of these correct? I've assumed optional strings
|
||||||
artist=None, artist_id=None, length=None, index=None,
|
def __init__(
|
||||||
medium=None, medium_index=None, medium_total=None,
|
self,
|
||||||
artist_sort=None, disctitle=None, artist_credit=None,
|
title: Optional[str] = None,
|
||||||
data_source=None, data_url=None, media=None, lyricist=None,
|
track_id: Optional[str] = None,
|
||||||
composer=None, composer_sort=None, arranger=None,
|
release_track_id: Optional[str] = None,
|
||||||
track_alt=None, work=None, mb_workid=None,
|
artist: Optional[str] = None,
|
||||||
work_disambig=None, bpm=None, initial_key=None, genre=None,
|
artist_id: Optional[str] = None,
|
||||||
**kwargs):
|
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,
|
||||||
|
):
|
||||||
self.title = title
|
self.title = title
|
||||||
self.track_id = track_id
|
self.track_id = track_id
|
||||||
self.release_track_id = release_track_id
|
self.release_track_id = release_track_id
|
||||||
self.artist = artist
|
self.artist = artist
|
||||||
self.artist_id = artist_id
|
self.artist_id = artist_id
|
||||||
|
self.artists = artists or []
|
||||||
|
self.artists_ids = artists_ids or []
|
||||||
self.length = length
|
self.length = length
|
||||||
self.index = index
|
self.index = index
|
||||||
self.media = media
|
self.media = media
|
||||||
@@ -175,8 +238,10 @@ class TrackInfo(AttrDict):
|
|||||||
self.medium_index = medium_index
|
self.medium_index = medium_index
|
||||||
self.medium_total = medium_total
|
self.medium_total = medium_total
|
||||||
self.artist_sort = artist_sort
|
self.artist_sort = artist_sort
|
||||||
|
self.artists_sort = artists_sort or []
|
||||||
self.disctitle = disctitle
|
self.disctitle = disctitle
|
||||||
self.artist_credit = artist_credit
|
self.artist_credit = artist_credit
|
||||||
|
self.artists_credit = artists_credit or []
|
||||||
self.data_source = data_source
|
self.data_source = data_source
|
||||||
self.data_url = data_url
|
self.data_url = data_url
|
||||||
self.lyricist = lyricist
|
self.lyricist = lyricist
|
||||||
@@ -190,20 +255,10 @@ class TrackInfo(AttrDict):
|
|||||||
self.bpm = bpm
|
self.bpm = bpm
|
||||||
self.initial_key = initial_key
|
self.initial_key = initial_key
|
||||||
self.genre = genre
|
self.genre = genre
|
||||||
|
self.album = album
|
||||||
self.update(kwargs)
|
self.update(kwargs)
|
||||||
|
|
||||||
# As above, work around a bug in python-musicbrainz-ngs.
|
def copy(self) -> TrackInfo:
|
||||||
def decode(self, codec='utf-8'):
|
|
||||||
"""Ensure that all string attributes on this object are decoded
|
|
||||||
to Unicode.
|
|
||||||
"""
|
|
||||||
for fld in ['title', 'artist', 'medium', 'artist_sort', 'disctitle',
|
|
||||||
'artist_credit', 'media']:
|
|
||||||
value = getattr(self, fld)
|
|
||||||
if isinstance(value, bytes):
|
|
||||||
setattr(self, fld, value.decode(codec, 'ignore'))
|
|
||||||
|
|
||||||
def copy(self):
|
|
||||||
dupe = TrackInfo()
|
dupe = TrackInfo()
|
||||||
dupe.update(self)
|
dupe.update(self)
|
||||||
return dupe
|
return dupe
|
||||||
@@ -213,23 +268,23 @@ class TrackInfo(AttrDict):
|
|||||||
|
|
||||||
# Parameters for string distance function.
|
# Parameters for string distance function.
|
||||||
# Words that can be moved to the end of a string using a comma.
|
# Words that can be moved to the end of a string using a comma.
|
||||||
SD_END_WORDS = ['the', 'a', 'an']
|
SD_END_WORDS = ["the", "a", "an"]
|
||||||
# Reduced weights for certain portions of the string.
|
# Reduced weights for certain portions of the string.
|
||||||
SD_PATTERNS = [
|
SD_PATTERNS = [
|
||||||
(r'^the ', 0.1),
|
(r"^the ", 0.1),
|
||||||
(r'[\[\(]?(ep|single)[\]\)]?', 0.0),
|
(r"[\[\(]?(ep|single)[\]\)]?", 0.0),
|
||||||
(r'[\[\(]?(featuring|feat|ft)[\. :].+', 0.1),
|
(r"[\[\(]?(featuring|feat|ft)[\. :].+", 0.1),
|
||||||
(r'\(.*?\)', 0.3),
|
(r"\(.*?\)", 0.3),
|
||||||
(r'\[.*?\]', 0.3),
|
(r"\[.*?\]", 0.3),
|
||||||
(r'(, )?(pt\.|part) .+', 0.2),
|
(r"(, )?(pt\.|part) .+", 0.2),
|
||||||
]
|
]
|
||||||
# Replacements to use before testing distance.
|
# Replacements to use before testing distance.
|
||||||
SD_REPLACE = [
|
SD_REPLACE = [
|
||||||
(r'&', 'and'),
|
(r"&", "and"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _string_dist_basic(str1, str2):
|
def _string_dist_basic(str1: str, str2: str) -> float:
|
||||||
"""Basic edit distance between two strings, ignoring
|
"""Basic edit distance between two strings, ignoring
|
||||||
non-alphanumeric characters and case. Comparisons are based on a
|
non-alphanumeric characters and case. Comparisons are based on a
|
||||||
transliteration/lowering to ASCII characters. Normalized by string
|
transliteration/lowering to ASCII characters. Normalized by string
|
||||||
@@ -239,14 +294,14 @@ def _string_dist_basic(str1, str2):
|
|||||||
assert isinstance(str2, str)
|
assert isinstance(str2, str)
|
||||||
str1 = as_string(unidecode(str1))
|
str1 = as_string(unidecode(str1))
|
||||||
str2 = as_string(unidecode(str2))
|
str2 = as_string(unidecode(str2))
|
||||||
str1 = re.sub(r'[^a-z0-9]', '', str1.lower())
|
str1 = re.sub(r"[^a-z0-9]", "", str1.lower())
|
||||||
str2 = re.sub(r'[^a-z0-9]', '', str2.lower())
|
str2 = re.sub(r"[^a-z0-9]", "", str2.lower())
|
||||||
if not str1 and not str2:
|
if not str1 and not str2:
|
||||||
return 0.0
|
return 0.0
|
||||||
return levenshtein_distance(str1, str2) / float(max(len(str1), len(str2)))
|
return levenshtein_distance(str1, str2) / float(max(len(str1), len(str2)))
|
||||||
|
|
||||||
|
|
||||||
def string_dist(str1, str2):
|
def string_dist(str1: Optional[str], str2: Optional[str]) -> float:
|
||||||
"""Gives an "intuitive" edit distance between two strings. This is
|
"""Gives an "intuitive" edit distance between two strings. This is
|
||||||
an edit distance, normalized by the string length, with a number of
|
an edit distance, normalized by the string length, with a number of
|
||||||
tweaks that reflect intuition about text.
|
tweaks that reflect intuition about text.
|
||||||
@@ -263,10 +318,10 @@ def string_dist(str1, str2):
|
|||||||
# example, "the something" should be considered equal to
|
# example, "the something" should be considered equal to
|
||||||
# "something, the".
|
# "something, the".
|
||||||
for word in SD_END_WORDS:
|
for word in SD_END_WORDS:
|
||||||
if str1.endswith(', %s' % word):
|
if str1.endswith(", %s" % word):
|
||||||
str1 = '{} {}'.format(word, str1[:-len(word) - 2])
|
str1 = "{} {}".format(word, str1[: -len(word) - 2])
|
||||||
if str2.endswith(', %s' % word):
|
if str2.endswith(", %s" % word):
|
||||||
str2 = '{} {}'.format(word, str2[:-len(word) - 2])
|
str2 = "{} {}".format(word, str2[: -len(word) - 2])
|
||||||
|
|
||||||
# Perform a couple of basic normalizing substitutions.
|
# Perform a couple of basic normalizing substitutions.
|
||||||
for pat, repl in SD_REPLACE:
|
for pat, repl in SD_REPLACE:
|
||||||
@@ -281,8 +336,8 @@ def string_dist(str1, str2):
|
|||||||
penalty = 0.0
|
penalty = 0.0
|
||||||
for pat, weight in SD_PATTERNS:
|
for pat, weight in SD_PATTERNS:
|
||||||
# Get strings that drop the pattern.
|
# Get strings that drop the pattern.
|
||||||
case_str1 = re.sub(pat, '', str1)
|
case_str1 = re.sub(pat, "", str1)
|
||||||
case_str2 = re.sub(pat, '', str2)
|
case_str2 = re.sub(pat, "", str2)
|
||||||
|
|
||||||
if case_str1 != str1 or case_str2 != str2:
|
if case_str1 != str1 or case_str2 != str2:
|
||||||
# If the pattern was present (i.e., it is deleted in the
|
# If the pattern was present (i.e., it is deleted in the
|
||||||
@@ -304,23 +359,6 @@ def string_dist(str1, str2):
|
|||||||
return base_dist + penalty
|
return base_dist + penalty
|
||||||
|
|
||||||
|
|
||||||
class LazyClassProperty:
|
|
||||||
"""A decorator implementing a read-only property that is *lazy* in
|
|
||||||
the sense that the getter is only invoked once. Subsequent accesses
|
|
||||||
through *any* instance use the cached result.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, getter):
|
|
||||||
self.getter = getter
|
|
||||||
self.computed = False
|
|
||||||
|
|
||||||
def __get__(self, obj, owner):
|
|
||||||
if not self.computed:
|
|
||||||
self.value = self.getter(owner)
|
|
||||||
self.computed = True
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
|
|
||||||
@total_ordering
|
@total_ordering
|
||||||
class Distance:
|
class Distance:
|
||||||
"""Keeps track of multiple distance penalties. Provides a single
|
"""Keeps track of multiple distance penalties. Provides a single
|
||||||
@@ -330,12 +368,12 @@ class Distance:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._penalties = {}
|
self._penalties = {}
|
||||||
|
self.tracks: Dict[TrackInfo, Distance] = {}
|
||||||
|
|
||||||
@LazyClassProperty
|
@cached_classproperty
|
||||||
def _weights(cls): # noqa: N805
|
def _weights(cls) -> Dict[str, float]: # noqa: N805
|
||||||
"""A dictionary from keys to floating-point weights.
|
"""A dictionary from keys to floating-point weights."""
|
||||||
"""
|
weights_view = config["match"]["distance_weights"]
|
||||||
weights_view = config['match']['distance_weights']
|
|
||||||
weights = {}
|
weights = {}
|
||||||
for key in weights_view.keys():
|
for key in weights_view.keys():
|
||||||
weights[key] = weights_view[key].as_number()
|
weights[key] = weights_view[key].as_number()
|
||||||
@@ -344,7 +382,7 @@ class Distance:
|
|||||||
# Access the components and their aggregates.
|
# Access the components and their aggregates.
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def distance(self):
|
def distance(self) -> float:
|
||||||
"""Return a weighted and normalized distance across all
|
"""Return a weighted and normalized distance across all
|
||||||
penalties.
|
penalties.
|
||||||
"""
|
"""
|
||||||
@@ -354,24 +392,22 @@ class Distance:
|
|||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def max_distance(self):
|
def max_distance(self) -> float:
|
||||||
"""Return the maximum distance penalty (normalization factor).
|
"""Return the maximum distance penalty (normalization factor)."""
|
||||||
"""
|
|
||||||
dist_max = 0.0
|
dist_max = 0.0
|
||||||
for key, penalty in self._penalties.items():
|
for key, penalty in self._penalties.items():
|
||||||
dist_max += len(penalty) * self._weights[key]
|
dist_max += len(penalty) * self._weights[key]
|
||||||
return dist_max
|
return dist_max
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def raw_distance(self):
|
def raw_distance(self) -> float:
|
||||||
"""Return the raw (denormalized) distance.
|
"""Return the raw (denormalized) distance."""
|
||||||
"""
|
|
||||||
dist_raw = 0.0
|
dist_raw = 0.0
|
||||||
for key, penalty in self._penalties.items():
|
for key, penalty in self._penalties.items():
|
||||||
dist_raw += sum(penalty) * self._weights[key]
|
dist_raw += sum(penalty) * self._weights[key]
|
||||||
return dist_raw
|
return dist_raw
|
||||||
|
|
||||||
def items(self):
|
def items(self) -> List[Tuple[str, float]]:
|
||||||
"""Return a list of (key, dist) pairs, with `dist` being the
|
"""Return a list of (key, dist) pairs, with `dist` being the
|
||||||
weighted distance, sorted from highest to lowest. Does not
|
weighted distance, sorted from highest to lowest. Does not
|
||||||
include penalties with a zero value.
|
include penalties with a zero value.
|
||||||
@@ -385,87 +421,88 @@ class Distance:
|
|||||||
# ascending order (for keys, when the penalty is equal) and
|
# ascending order (for keys, when the penalty is equal) and
|
||||||
# still get the items with the biggest distance first.
|
# still get the items with the biggest distance first.
|
||||||
return sorted(
|
return sorted(
|
||||||
list_,
|
list_, key=lambda key_and_dist: (-key_and_dist[1], key_and_dist[0])
|
||||||
key=lambda key_and_dist: (-key_and_dist[1], key_and_dist[0])
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def __hash__(self):
|
def __hash__(self) -> int:
|
||||||
return id(self)
|
return id(self)
|
||||||
|
|
||||||
def __eq__(self, other):
|
def __eq__(self, other) -> bool:
|
||||||
return self.distance == other
|
return self.distance == other
|
||||||
|
|
||||||
# Behave like a float.
|
# Behave like a float.
|
||||||
|
|
||||||
def __lt__(self, other):
|
def __lt__(self, other) -> bool:
|
||||||
return self.distance < other
|
return self.distance < other
|
||||||
|
|
||||||
def __float__(self):
|
def __float__(self) -> float:
|
||||||
return self.distance
|
return self.distance
|
||||||
|
|
||||||
def __sub__(self, other):
|
def __sub__(self, other) -> float:
|
||||||
return self.distance - other
|
return self.distance - other
|
||||||
|
|
||||||
def __rsub__(self, other):
|
def __rsub__(self, other) -> float:
|
||||||
return other - self.distance
|
return other - self.distance
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return f"{self.distance:.2f}"
|
return f"{self.distance:.2f}"
|
||||||
|
|
||||||
# Behave like a dict.
|
# Behave like a dict.
|
||||||
|
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key) -> float:
|
||||||
"""Returns the weighted distance for a named penalty.
|
"""Returns the weighted distance for a named penalty."""
|
||||||
"""
|
|
||||||
dist = sum(self._penalties[key]) * self._weights[key]
|
dist = sum(self._penalties[key]) * self._weights[key]
|
||||||
dist_max = self.max_distance
|
dist_max = self.max_distance
|
||||||
if dist_max:
|
if dist_max:
|
||||||
return dist / dist_max
|
return dist / dist_max
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
def __iter__(self):
|
def __iter__(self) -> Iterator[Tuple[str, float]]:
|
||||||
return iter(self.items())
|
return iter(self.items())
|
||||||
|
|
||||||
def __len__(self):
|
def __len__(self) -> int:
|
||||||
return len(self.items())
|
return len(self.items())
|
||||||
|
|
||||||
def keys(self):
|
def keys(self) -> List[str]:
|
||||||
return [key for key, _ in self.items()]
|
return [key for key, _ in self.items()]
|
||||||
|
|
||||||
def update(self, dist):
|
def update(self, dist: "Distance"):
|
||||||
"""Adds all the distance penalties from `dist`.
|
"""Adds all the distance penalties from `dist`."""
|
||||||
"""
|
|
||||||
if not isinstance(dist, Distance):
|
if not isinstance(dist, Distance):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
'`dist` must be a Distance object, not {}'.format(type(dist))
|
"`dist` must be a Distance object, not {}".format(type(dist))
|
||||||
)
|
)
|
||||||
for key, penalties in dist._penalties.items():
|
for key, penalties in dist._penalties.items():
|
||||||
self._penalties.setdefault(key, []).extend(penalties)
|
self._penalties.setdefault(key, []).extend(penalties)
|
||||||
|
|
||||||
# Adding components.
|
# Adding components.
|
||||||
|
|
||||||
def _eq(self, value1, value2):
|
def _eq(self, value1: Union[re.Pattern[str], Any], value2: Any) -> bool:
|
||||||
"""Returns True if `value1` is equal to `value2`. `value1` may
|
"""Returns True if `value1` is equal to `value2`. `value1` may
|
||||||
be a compiled regular expression, in which case it will be
|
be a compiled regular expression, in which case it will be
|
||||||
matched against `value2`.
|
matched against `value2`.
|
||||||
"""
|
"""
|
||||||
if isinstance(value1, Pattern):
|
if isinstance(value1, re.Pattern):
|
||||||
|
value2 = cast(str, value2)
|
||||||
return bool(value1.match(value2))
|
return bool(value1.match(value2))
|
||||||
return value1 == value2
|
return value1 == value2
|
||||||
|
|
||||||
def add(self, key, dist):
|
def add(self, key: str, dist: float):
|
||||||
"""Adds a distance penalty. `key` must correspond with a
|
"""Adds a distance penalty. `key` must correspond with a
|
||||||
configured weight setting. `dist` must be a float between 0.0
|
configured weight setting. `dist` must be a float between 0.0
|
||||||
and 1.0, and will be added to any existing distance penalties
|
and 1.0, and will be added to any existing distance penalties
|
||||||
for the same key.
|
for the same key.
|
||||||
"""
|
"""
|
||||||
if not 0.0 <= dist <= 1.0:
|
if not 0.0 <= dist <= 1.0:
|
||||||
raise ValueError(
|
raise ValueError(f"`dist` must be between 0.0 and 1.0, not {dist}")
|
||||||
f'`dist` must be between 0.0 and 1.0, not {dist}'
|
|
||||||
)
|
|
||||||
self._penalties.setdefault(key, []).append(dist)
|
self._penalties.setdefault(key, []).append(dist)
|
||||||
|
|
||||||
def add_equality(self, key, value, options):
|
def add_equality(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
value: Any,
|
||||||
|
options: Union[List[Any], Tuple[Any, ...], Any],
|
||||||
|
):
|
||||||
"""Adds a distance penalty of 1.0 if `value` doesn't match any
|
"""Adds a distance penalty of 1.0 if `value` doesn't match any
|
||||||
of the values in `options`. If an option is a compiled regular
|
of the values in `options`. If an option is a compiled regular
|
||||||
expression, it will be considered equal if it matches against
|
expression, it will be considered equal if it matches against
|
||||||
@@ -481,7 +518,7 @@ class Distance:
|
|||||||
dist = 1.0
|
dist = 1.0
|
||||||
self.add(key, dist)
|
self.add(key, dist)
|
||||||
|
|
||||||
def add_expr(self, key, expr):
|
def add_expr(self, key: str, expr: bool):
|
||||||
"""Adds a distance penalty of 1.0 if `expr` evaluates to True,
|
"""Adds a distance penalty of 1.0 if `expr` evaluates to True,
|
||||||
or 0.0.
|
or 0.0.
|
||||||
"""
|
"""
|
||||||
@@ -490,7 +527,7 @@ class Distance:
|
|||||||
else:
|
else:
|
||||||
self.add(key, 0.0)
|
self.add(key, 0.0)
|
||||||
|
|
||||||
def add_number(self, key, number1, number2):
|
def add_number(self, key: str, number1: int, number2: int):
|
||||||
"""Adds a distance penalty of 1.0 for each number of difference
|
"""Adds a distance penalty of 1.0 for each number of difference
|
||||||
between `number1` and `number2`, or 0.0 when there is no
|
between `number1` and `number2`, or 0.0 when there is no
|
||||||
difference. Use this when there is no upper limit on the
|
difference. Use this when there is no upper limit on the
|
||||||
@@ -503,7 +540,12 @@ class Distance:
|
|||||||
else:
|
else:
|
||||||
self.add(key, 0.0)
|
self.add(key, 0.0)
|
||||||
|
|
||||||
def add_priority(self, key, value, options):
|
def add_priority(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
value: Any,
|
||||||
|
options: Union[List[Any], Tuple[Any, ...], Any],
|
||||||
|
):
|
||||||
"""Adds a distance penalty that corresponds to the position at
|
"""Adds a distance penalty that corresponds to the position at
|
||||||
which `value` appears in `options`. A distance penalty of 0.0
|
which `value` appears in `options`. A distance penalty of 0.0
|
||||||
for the first option, or 1.0 if there is no matching option. If
|
for the first option, or 1.0 if there is no matching option. If
|
||||||
@@ -521,7 +563,12 @@ class Distance:
|
|||||||
dist = 1.0
|
dist = 1.0
|
||||||
self.add(key, dist)
|
self.add(key, dist)
|
||||||
|
|
||||||
def add_ratio(self, key, number1, number2):
|
def add_ratio(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
number1: Union[int, float],
|
||||||
|
number2: Union[int, float],
|
||||||
|
):
|
||||||
"""Adds a distance penalty for `number1` as a ratio of `number2`.
|
"""Adds a distance penalty for `number1` as a ratio of `number2`.
|
||||||
`number1` is bound at 0 and `number2`.
|
`number1` is bound at 0 and `number2`.
|
||||||
"""
|
"""
|
||||||
@@ -532,7 +579,7 @@ class Distance:
|
|||||||
dist = 0.0
|
dist = 0.0
|
||||||
self.add(key, dist)
|
self.add(key, dist)
|
||||||
|
|
||||||
def add_string(self, key, str1, str2):
|
def add_string(self, key: str, str1: Optional[str], str2: Optional[str]):
|
||||||
"""Adds a distance penalty based on the edit distance between
|
"""Adds a distance penalty based on the edit distance between
|
||||||
`str1` and `str2`.
|
`str1` and `str2`.
|
||||||
"""
|
"""
|
||||||
@@ -542,64 +589,82 @@ class Distance:
|
|||||||
|
|
||||||
# Structures that compose all the information for a candidate match.
|
# Structures that compose all the information for a candidate match.
|
||||||
|
|
||||||
AlbumMatch = namedtuple('AlbumMatch', ['distance', 'info', 'mapping',
|
AlbumMatch = namedtuple(
|
||||||
'extra_items', 'extra_tracks'])
|
"AlbumMatch", ["distance", "info", "mapping", "extra_items", "extra_tracks"]
|
||||||
|
)
|
||||||
|
|
||||||
TrackMatch = namedtuple('TrackMatch', ['distance', 'info'])
|
TrackMatch = namedtuple("TrackMatch", ["distance", "info"])
|
||||||
|
|
||||||
|
|
||||||
# Aggregation of sources.
|
# Aggregation of sources.
|
||||||
|
|
||||||
def album_for_mbid(release_id):
|
|
||||||
|
def album_for_mbid(release_id: str) -> Optional[AlbumInfo]:
|
||||||
"""Get an AlbumInfo object for a MusicBrainz release ID. Return None
|
"""Get an AlbumInfo object for a MusicBrainz release ID. Return None
|
||||||
if the ID is not found.
|
if the ID is not found.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
album = mb.album_for_id(release_id)
|
album = mb.album_for_id(release_id)
|
||||||
if album:
|
if album:
|
||||||
plugins.send('albuminfo_received', info=album)
|
plugins.send("albuminfo_received", info=album)
|
||||||
return album
|
return album
|
||||||
except mb.MusicBrainzAPIError as exc:
|
except mb.MusicBrainzAPIError as exc:
|
||||||
exc.log(log)
|
exc.log(log)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def track_for_mbid(recording_id):
|
def track_for_mbid(recording_id: str) -> Optional[TrackInfo]:
|
||||||
"""Get a TrackInfo object for a MusicBrainz recording ID. Return None
|
"""Get a TrackInfo object for a MusicBrainz recording ID. Return None
|
||||||
if the ID is not found.
|
if the ID is not found.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
track = mb.track_for_id(recording_id)
|
track = mb.track_for_id(recording_id)
|
||||||
if track:
|
if track:
|
||||||
plugins.send('trackinfo_received', info=track)
|
plugins.send("trackinfo_received", info=track)
|
||||||
return track
|
return track
|
||||||
except mb.MusicBrainzAPIError as exc:
|
except mb.MusicBrainzAPIError as exc:
|
||||||
exc.log(log)
|
exc.log(log)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def albums_for_id(album_id):
|
def albums_for_id(album_id: str) -> Iterable[AlbumInfo]:
|
||||||
"""Get a list of albums for an ID."""
|
"""Get a list of albums for an ID."""
|
||||||
a = album_for_mbid(album_id)
|
a = album_for_mbid(album_id)
|
||||||
if a:
|
if a:
|
||||||
yield a
|
yield a
|
||||||
for a in plugins.album_for_id(album_id):
|
for a in plugins.album_for_id(album_id):
|
||||||
if a:
|
if a:
|
||||||
plugins.send('albuminfo_received', info=a)
|
plugins.send("albuminfo_received", info=a)
|
||||||
yield a
|
yield a
|
||||||
|
|
||||||
|
|
||||||
def tracks_for_id(track_id):
|
def tracks_for_id(track_id: str) -> Iterable[TrackInfo]:
|
||||||
"""Get a list of tracks for an ID."""
|
"""Get a list of tracks for an ID."""
|
||||||
t = track_for_mbid(track_id)
|
t = track_for_mbid(track_id)
|
||||||
if t:
|
if t:
|
||||||
yield t
|
yield t
|
||||||
for t in plugins.track_for_id(track_id):
|
for t in plugins.track_for_id(track_id):
|
||||||
if t:
|
if t:
|
||||||
plugins.send('trackinfo_received', info=t)
|
plugins.send("trackinfo_received", info=t)
|
||||||
yield t
|
yield t
|
||||||
|
|
||||||
|
|
||||||
@plugins.notify_info_yielded('albuminfo_received')
|
def invoke_mb(call_func: Callable, *args):
|
||||||
def album_candidates(items, artist, album, va_likely, extra_tags):
|
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]:
|
||||||
"""Search for album matches. ``items`` is a list of Item objects
|
"""Search for album matches. ``items`` is a list of Item objects
|
||||||
that make up the album. ``artist`` and ``album`` are the respective
|
that make up the album. ``artist`` and ``album`` are the respective
|
||||||
names (strings), which may be derived from the item list or may be
|
names (strings), which may be derived from the item list or may be
|
||||||
@@ -609,40 +674,33 @@ def album_candidates(items, artist, album, va_likely, extra_tags):
|
|||||||
constrain the search.
|
constrain the search.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Base candidates if we have album and artist to match.
|
if config["musicbrainz"]["enabled"]:
|
||||||
if artist and album:
|
# Base candidates if we have album and artist to match.
|
||||||
try:
|
if artist and album:
|
||||||
yield from mb.match_album(artist, album, len(items),
|
yield from invoke_mb(
|
||||||
extra_tags)
|
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.
|
# Also add VA matches from MusicBrainz where appropriate.
|
||||||
if va_likely and album:
|
if va_likely and album:
|
||||||
try:
|
yield from invoke_mb(
|
||||||
yield from mb.match_album(None, album, len(items),
|
mb.match_album, None, album, len(items), extra_tags
|
||||||
extra_tags)
|
)
|
||||||
except mb.MusicBrainzAPIError as exc:
|
|
||||||
exc.log(log)
|
|
||||||
|
|
||||||
# Candidates from plugins.
|
# Candidates from plugins.
|
||||||
yield from plugins.candidates(items, artist, album, va_likely,
|
yield from plugins.candidates(items, artist, album, va_likely, extra_tags)
|
||||||
extra_tags)
|
|
||||||
|
|
||||||
|
|
||||||
@plugins.notify_info_yielded('trackinfo_received')
|
@plugins.notify_info_yielded("trackinfo_received")
|
||||||
def item_candidates(item, artist, title):
|
def item_candidates(item: Item, artist: str, title: str) -> Iterable[Tuple]:
|
||||||
"""Search for item matches. ``item`` is the Item to be matched.
|
"""Search for item matches. ``item`` is the Item to be matched.
|
||||||
``artist`` and ``title`` are strings and either reflect the item or
|
``artist`` and ``title`` are strings and either reflect the item or
|
||||||
are specified by the user.
|
are specified by the user.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# MusicBrainz candidates.
|
# MusicBrainz candidates.
|
||||||
if artist and title:
|
if config["musicbrainz"]["enabled"] and artist and title:
|
||||||
try:
|
yield from invoke_mb(mb.match_track, artist, title)
|
||||||
yield from mb.match_track(artist, title)
|
|
||||||
except mb.MusicBrainzAPIError as exc:
|
|
||||||
exc.log(log)
|
|
||||||
|
|
||||||
# Plugin candidates.
|
# Plugin candidates.
|
||||||
yield from plugins.item_candidates(item, artist, title)
|
yield from plugins.item_candidates(item, artist, title)
|
||||||
|
|||||||
+245
-149
@@ -19,32 +19,53 @@ releases and tracks.
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import re
|
import re
|
||||||
from munkres import Munkres
|
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
from typing import (
|
||||||
|
Any,
|
||||||
|
Dict,
|
||||||
|
Iterable,
|
||||||
|
List,
|
||||||
|
Optional,
|
||||||
|
Sequence,
|
||||||
|
Tuple,
|
||||||
|
TypeVar,
|
||||||
|
Union,
|
||||||
|
cast,
|
||||||
|
)
|
||||||
|
|
||||||
from beets import logging
|
from munkres import Munkres
|
||||||
from beets import plugins
|
|
||||||
from beets import config
|
from beets import config, logging, plugins
|
||||||
|
from beets.autotag import (
|
||||||
|
AlbumInfo,
|
||||||
|
AlbumMatch,
|
||||||
|
Distance,
|
||||||
|
TrackInfo,
|
||||||
|
TrackMatch,
|
||||||
|
hooks,
|
||||||
|
)
|
||||||
|
from beets.library import Item
|
||||||
from beets.util import plurality
|
from beets.util import plurality
|
||||||
from beets.autotag import hooks
|
|
||||||
from beets.util.enumeration import OrderedEnum
|
from beets.util.enumeration import OrderedEnum
|
||||||
|
|
||||||
# Artist signals that indicate "various artists". These are used at the
|
# Artist signals that indicate "various artists". These are used at the
|
||||||
# album level to determine whether a given release is likely a VA
|
# album level to determine whether a given release is likely a VA
|
||||||
# release and also on the track level to to remove the penalty for
|
# release and also on the track level to to remove the penalty for
|
||||||
# differing artists.
|
# differing artists.
|
||||||
VA_ARTISTS = ('', 'various artists', 'various', 'va', 'unknown')
|
VA_ARTISTS = ("", "various artists", "various", "va", "unknown")
|
||||||
|
|
||||||
# Global logger.
|
# Global logger.
|
||||||
log = logging.getLogger('beets')
|
log = logging.getLogger("beets")
|
||||||
|
|
||||||
|
|
||||||
# Recommendation enumeration.
|
# Recommendation enumeration.
|
||||||
|
|
||||||
|
|
||||||
class Recommendation(OrderedEnum):
|
class Recommendation(OrderedEnum):
|
||||||
"""Indicates a qualitative suggestion to the user about what should
|
"""Indicates a qualitative suggestion to the user about what should
|
||||||
be done with a given match.
|
be done with a given match.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
none = 0
|
none = 0
|
||||||
low = 1
|
low = 1
|
||||||
medium = 2
|
medium = 2
|
||||||
@@ -55,12 +76,15 @@ class Recommendation(OrderedEnum):
|
|||||||
# consists of a list of possible candidates (i.e., AlbumInfo or TrackInfo
|
# consists of a list of possible candidates (i.e., AlbumInfo or TrackInfo
|
||||||
# objects) and a recommendation value.
|
# objects) and a recommendation value.
|
||||||
|
|
||||||
Proposal = namedtuple('Proposal', ('candidates', 'recommendation'))
|
Proposal = namedtuple("Proposal", ("candidates", "recommendation"))
|
||||||
|
|
||||||
|
|
||||||
# Primary matching functionality.
|
# Primary matching functionality.
|
||||||
|
|
||||||
def current_metadata(items):
|
|
||||||
|
def current_metadata(
|
||||||
|
items: Iterable[Item],
|
||||||
|
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||||
"""Extract the likely current metadata for an album given a list of its
|
"""Extract the likely current metadata for an album given a list of its
|
||||||
items. Return two dictionaries:
|
items. Return two dictionaries:
|
||||||
- The most common value for each field.
|
- The most common value for each field.
|
||||||
@@ -70,22 +94,36 @@ def current_metadata(items):
|
|||||||
|
|
||||||
likelies = {}
|
likelies = {}
|
||||||
consensus = {}
|
consensus = {}
|
||||||
fields = ['artist', 'album', 'albumartist', 'year', 'disctotal',
|
fields = [
|
||||||
'mb_albumid', 'label', 'catalognum', 'country', 'media',
|
"artist",
|
||||||
'albumdisambig']
|
"album",
|
||||||
|
"albumartist",
|
||||||
|
"year",
|
||||||
|
"disctotal",
|
||||||
|
"mb_albumid",
|
||||||
|
"label",
|
||||||
|
"barcode",
|
||||||
|
"catalognum",
|
||||||
|
"country",
|
||||||
|
"media",
|
||||||
|
"albumdisambig",
|
||||||
|
]
|
||||||
for field in fields:
|
for field in fields:
|
||||||
values = [item[field] for item in items if item]
|
values = [item[field] for item in items if item]
|
||||||
likelies[field], freq = plurality(values)
|
likelies[field], freq = plurality(values)
|
||||||
consensus[field] = (freq == len(values))
|
consensus[field] = freq == len(values)
|
||||||
|
|
||||||
# If there's an album artist consensus, use this for the artist.
|
# If there's an album artist consensus, use this for the artist.
|
||||||
if consensus['albumartist'] and likelies['albumartist']:
|
if consensus["albumartist"] and likelies["albumartist"]:
|
||||||
likelies['artist'] = likelies['albumartist']
|
likelies["artist"] = likelies["albumartist"]
|
||||||
|
|
||||||
return likelies, consensus
|
return likelies, consensus
|
||||||
|
|
||||||
|
|
||||||
def assign_items(items, tracks):
|
def assign_items(
|
||||||
|
items: Sequence[Item],
|
||||||
|
tracks: Sequence[TrackInfo],
|
||||||
|
) -> Tuple[Dict[Item, TrackInfo], List[Item], List[TrackInfo]]:
|
||||||
"""Given a list of Items and a list of TrackInfo objects, find the
|
"""Given a list of Items and a list of TrackInfo objects, find the
|
||||||
best mapping between them. Returns a mapping from Items to TrackInfo
|
best mapping between them. Returns a mapping from Items to TrackInfo
|
||||||
objects, a set of extra Items, and a set of extra TrackInfo
|
objects, a set of extra Items, and a set of extra TrackInfo
|
||||||
@@ -93,17 +131,17 @@ def assign_items(items, tracks):
|
|||||||
of objects of the two types.
|
of objects of the two types.
|
||||||
"""
|
"""
|
||||||
# Construct the cost matrix.
|
# Construct the cost matrix.
|
||||||
costs = []
|
costs: List[List[Distance]] = []
|
||||||
for item in items:
|
for item in items:
|
||||||
row = []
|
row = []
|
||||||
for i, track in enumerate(tracks):
|
for track in tracks:
|
||||||
row.append(track_distance(item, track))
|
row.append(track_distance(item, track))
|
||||||
costs.append(row)
|
costs.append(row)
|
||||||
|
|
||||||
# Find a minimum-cost bipartite matching.
|
# Find a minimum-cost bipartite matching.
|
||||||
log.debug('Computing track assignment...')
|
log.debug("Computing track assignment...")
|
||||||
matching = Munkres().compute(costs)
|
matching = Munkres().compute(costs)
|
||||||
log.debug('...done.')
|
log.debug("...done.")
|
||||||
|
|
||||||
# Produce the output matching.
|
# Produce the output matching.
|
||||||
mapping = {items[i]: tracks[j] for (i, j) in matching}
|
mapping = {items[i]: tracks[j] for (i, j) in matching}
|
||||||
@@ -114,14 +152,18 @@ def assign_items(items, tracks):
|
|||||||
return mapping, extra_items, extra_tracks
|
return mapping, extra_items, extra_tracks
|
||||||
|
|
||||||
|
|
||||||
def track_index_changed(item, track_info):
|
def track_index_changed(item: Item, track_info: TrackInfo) -> bool:
|
||||||
"""Returns True if the item and track info index is different. Tolerates
|
"""Returns True if the item and track info index is different. Tolerates
|
||||||
per disc and per release numbering.
|
per disc and per release numbering.
|
||||||
"""
|
"""
|
||||||
return item.track not in (track_info.medium_index, track_info.index)
|
return item.track not in (track_info.medium_index, track_info.index)
|
||||||
|
|
||||||
|
|
||||||
def track_distance(item, track_info, incl_artist=False):
|
def track_distance(
|
||||||
|
item: Item,
|
||||||
|
track_info: TrackInfo,
|
||||||
|
incl_artist: bool = False,
|
||||||
|
) -> Distance:
|
||||||
"""Determines the significance of a track metadata change. Returns a
|
"""Determines the significance of a track metadata change. Returns a
|
||||||
Distance object. `incl_artist` indicates that a distance component should
|
Distance object. `incl_artist` indicates that a distance component should
|
||||||
be included for the track artist (i.e., for various-artist releases).
|
be included for the track artist (i.e., for various-artist releases).
|
||||||
@@ -130,26 +172,37 @@ def track_distance(item, track_info, incl_artist=False):
|
|||||||
|
|
||||||
# Length.
|
# Length.
|
||||||
if track_info.length:
|
if track_info.length:
|
||||||
diff = abs(item.length - track_info.length) - \
|
item_length = cast(float, item.length)
|
||||||
config['match']['track_length_grace'].as_number()
|
track_length_grace = cast(
|
||||||
dist.add_ratio('track_length', diff,
|
Union[float, int],
|
||||||
config['match']['track_length_max'].as_number())
|
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)
|
||||||
|
|
||||||
# Title.
|
# Title.
|
||||||
dist.add_string('track_title', item.title, track_info.title)
|
dist.add_string("track_title", item.title, track_info.title)
|
||||||
|
|
||||||
# Artist. Only check if there is actually an artist in the track data.
|
# Artist. Only check if there is actually an artist in the track data.
|
||||||
if incl_artist and track_info.artist and \
|
if (
|
||||||
item.artist.lower() not in VA_ARTISTS:
|
incl_artist
|
||||||
dist.add_string('track_artist', item.artist, track_info.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.
|
# Track index.
|
||||||
if track_info.index and item.track:
|
if track_info.index and item.track:
|
||||||
dist.add_expr('track_index', track_index_changed(item, track_info))
|
dist.add_expr("track_index", track_index_changed(item, track_info))
|
||||||
|
|
||||||
# Track ID.
|
# Track ID.
|
||||||
if item.mb_trackid:
|
if item.mb_trackid:
|
||||||
dist.add_expr('track_id', item.mb_trackid != track_info.track_id)
|
dist.add_expr("track_id", item.mb_trackid != track_info.track_id)
|
||||||
|
|
||||||
# Plugins.
|
# Plugins.
|
||||||
dist.update(plugins.track_distance(item, track_info))
|
dist.update(plugins.track_distance(item, track_info))
|
||||||
@@ -157,7 +210,11 @@ def track_distance(item, track_info, incl_artist=False):
|
|||||||
return dist
|
return dist
|
||||||
|
|
||||||
|
|
||||||
def distance(items, album_info, mapping):
|
def distance(
|
||||||
|
items: Sequence[Item],
|
||||||
|
album_info: AlbumInfo,
|
||||||
|
mapping: Dict[Item, TrackInfo],
|
||||||
|
) -> Distance:
|
||||||
"""Determines how "significant" an album metadata change would be.
|
"""Determines how "significant" an album metadata change would be.
|
||||||
Returns a Distance object. `album_info` is an AlbumInfo object
|
Returns a Distance object. `album_info` is an AlbumInfo object
|
||||||
reflecting the album to be compared. `items` is a sequence of all
|
reflecting the album to be compared. `items` is a sequence of all
|
||||||
@@ -172,90 +229,96 @@ def distance(items, album_info, mapping):
|
|||||||
|
|
||||||
# Artist, if not various.
|
# Artist, if not various.
|
||||||
if not album_info.va:
|
if not album_info.va:
|
||||||
dist.add_string('artist', likelies['artist'], album_info.artist)
|
dist.add_string("artist", likelies["artist"], album_info.artist)
|
||||||
|
|
||||||
# Album.
|
# Album.
|
||||||
dist.add_string('album', likelies['album'], album_info.album)
|
dist.add_string("album", likelies["album"], album_info.album)
|
||||||
|
|
||||||
# Current or preferred media.
|
# Current or preferred media.
|
||||||
if album_info.media:
|
if album_info.media:
|
||||||
# Preferred media options.
|
# Preferred media options.
|
||||||
patterns = config['match']['preferred']['media'].as_str_seq()
|
patterns = config["match"]["preferred"]["media"].as_str_seq()
|
||||||
options = [re.compile(r'(\d+x)?(%s)' % pat, re.I) for pat in patterns]
|
patterns = cast(Sequence[str], patterns)
|
||||||
|
options = [re.compile(r"(\d+x)?(%s)" % pat, re.I) for pat in patterns]
|
||||||
if options:
|
if options:
|
||||||
dist.add_priority('media', album_info.media, options)
|
dist.add_priority("media", album_info.media, options)
|
||||||
# Current media.
|
# Current media.
|
||||||
elif likelies['media']:
|
elif likelies["media"]:
|
||||||
dist.add_equality('media', album_info.media, likelies['media'])
|
dist.add_equality("media", album_info.media, likelies["media"])
|
||||||
|
|
||||||
# Mediums.
|
# Mediums.
|
||||||
if likelies['disctotal'] and album_info.mediums:
|
if likelies["disctotal"] and album_info.mediums:
|
||||||
dist.add_number('mediums', likelies['disctotal'], album_info.mediums)
|
dist.add_number("mediums", likelies["disctotal"], album_info.mediums)
|
||||||
|
|
||||||
# Prefer earliest release.
|
# Prefer earliest release.
|
||||||
if album_info.year and config['match']['preferred']['original_year']:
|
if album_info.year and config["match"]["preferred"]["original_year"]:
|
||||||
# Assume 1889 (earliest first gramophone discs) if we don't know the
|
# Assume 1889 (earliest first gramophone discs) if we don't know the
|
||||||
# original year.
|
# original year.
|
||||||
original = album_info.original_year or 1889
|
original = album_info.original_year or 1889
|
||||||
diff = abs(album_info.year - original)
|
diff = abs(album_info.year - original)
|
||||||
diff_max = abs(datetime.date.today().year - original)
|
diff_max = abs(datetime.date.today().year - original)
|
||||||
dist.add_ratio('year', diff, diff_max)
|
dist.add_ratio("year", diff, diff_max)
|
||||||
# Year.
|
# Year.
|
||||||
elif likelies['year'] and album_info.year:
|
elif likelies["year"] and album_info.year:
|
||||||
if likelies['year'] in (album_info.year, album_info.original_year):
|
if likelies["year"] in (album_info.year, album_info.original_year):
|
||||||
# No penalty for matching release or original year.
|
# No penalty for matching release or original year.
|
||||||
dist.add('year', 0.0)
|
dist.add("year", 0.0)
|
||||||
elif album_info.original_year:
|
elif album_info.original_year:
|
||||||
# Prefer matchest closest to the release year.
|
# Prefer matchest closest to the release year.
|
||||||
diff = abs(likelies['year'] - album_info.year)
|
diff = abs(likelies["year"] - album_info.year)
|
||||||
diff_max = abs(datetime.date.today().year -
|
diff_max = abs(
|
||||||
album_info.original_year)
|
datetime.date.today().year - album_info.original_year
|
||||||
dist.add_ratio('year', diff, diff_max)
|
)
|
||||||
|
dist.add_ratio("year", diff, diff_max)
|
||||||
else:
|
else:
|
||||||
# Full penalty when there is no original year.
|
# Full penalty when there is no original year.
|
||||||
dist.add('year', 1.0)
|
dist.add("year", 1.0)
|
||||||
|
|
||||||
# Preferred countries.
|
# Preferred countries.
|
||||||
patterns = config['match']['preferred']['countries'].as_str_seq()
|
patterns = config["match"]["preferred"]["countries"].as_str_seq()
|
||||||
|
patterns = cast(Sequence[str], patterns)
|
||||||
options = [re.compile(pat, re.I) for pat in patterns]
|
options = [re.compile(pat, re.I) for pat in patterns]
|
||||||
if album_info.country and options:
|
if album_info.country and options:
|
||||||
dist.add_priority('country', album_info.country, options)
|
dist.add_priority("country", album_info.country, options)
|
||||||
# Country.
|
# Country.
|
||||||
elif likelies['country'] and album_info.country:
|
elif likelies["country"] and album_info.country:
|
||||||
dist.add_string('country', likelies['country'], album_info.country)
|
dist.add_string("country", likelies["country"], album_info.country)
|
||||||
|
|
||||||
# Label.
|
# Label.
|
||||||
if likelies['label'] and album_info.label:
|
if likelies["label"] and album_info.label:
|
||||||
dist.add_string('label', likelies['label'], album_info.label)
|
dist.add_string("label", likelies["label"], album_info.label)
|
||||||
|
|
||||||
# Catalog number.
|
# Catalog number.
|
||||||
if likelies['catalognum'] and album_info.catalognum:
|
if likelies["catalognum"] and album_info.catalognum:
|
||||||
dist.add_string('catalognum', likelies['catalognum'],
|
dist.add_string(
|
||||||
album_info.catalognum)
|
"catalognum", likelies["catalognum"], album_info.catalognum
|
||||||
|
)
|
||||||
|
|
||||||
# Disambiguation.
|
# Disambiguation.
|
||||||
if likelies['albumdisambig'] and album_info.albumdisambig:
|
if likelies["albumdisambig"] and album_info.albumdisambig:
|
||||||
dist.add_string('albumdisambig', likelies['albumdisambig'],
|
dist.add_string(
|
||||||
album_info.albumdisambig)
|
"albumdisambig", likelies["albumdisambig"], album_info.albumdisambig
|
||||||
|
)
|
||||||
|
|
||||||
# Album ID.
|
# Album ID.
|
||||||
if likelies['mb_albumid']:
|
if likelies["mb_albumid"]:
|
||||||
dist.add_equality('album_id', likelies['mb_albumid'],
|
dist.add_equality(
|
||||||
album_info.album_id)
|
"album_id", likelies["mb_albumid"], album_info.album_id
|
||||||
|
)
|
||||||
|
|
||||||
# Tracks.
|
# Tracks.
|
||||||
dist.tracks = {}
|
dist.tracks = {}
|
||||||
for item, track in mapping.items():
|
for item, track in mapping.items():
|
||||||
dist.tracks[track] = track_distance(item, track, album_info.va)
|
dist.tracks[track] = track_distance(item, track, album_info.va)
|
||||||
dist.add('tracks', dist.tracks[track].distance)
|
dist.add("tracks", dist.tracks[track].distance)
|
||||||
|
|
||||||
# Missing tracks.
|
# Missing tracks.
|
||||||
for i in range(len(album_info.tracks) - len(mapping)):
|
for _ in range(len(album_info.tracks) - len(mapping)):
|
||||||
dist.add('missing_tracks', 1.0)
|
dist.add("missing_tracks", 1.0)
|
||||||
|
|
||||||
# Unmatched tracks.
|
# Unmatched tracks.
|
||||||
for i in range(len(items) - len(mapping)):
|
for _ in range(len(items) - len(mapping)):
|
||||||
dist.add('unmatched_tracks', 1.0)
|
dist.add("unmatched_tracks", 1.0)
|
||||||
|
|
||||||
# Plugins.
|
# Plugins.
|
||||||
dist.update(plugins.album_distance(items, album_info, mapping))
|
dist.update(plugins.album_distance(items, album_info, mapping))
|
||||||
@@ -263,7 +326,7 @@ def distance(items, album_info, mapping):
|
|||||||
return dist
|
return dist
|
||||||
|
|
||||||
|
|
||||||
def match_by_id(items):
|
def match_by_id(items: Iterable[Item]):
|
||||||
"""If the items are tagged with a MusicBrainz album ID, returns an
|
"""If the items are tagged with a MusicBrainz album ID, returns an
|
||||||
AlbumInfo object for the corresponding album. Otherwise, returns
|
AlbumInfo object for the corresponding album. Otherwise, returns
|
||||||
None.
|
None.
|
||||||
@@ -274,20 +337,22 @@ def match_by_id(items):
|
|||||||
try:
|
try:
|
||||||
first = next(albumids)
|
first = next(albumids)
|
||||||
except StopIteration:
|
except StopIteration:
|
||||||
log.debug('No album ID found.')
|
log.debug("No album ID found.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Is there a consensus on the MB album ID?
|
# Is there a consensus on the MB album ID?
|
||||||
for other in albumids:
|
for other in albumids:
|
||||||
if other != first:
|
if other != first:
|
||||||
log.debug('No album ID consensus.')
|
log.debug("No album ID consensus.")
|
||||||
return None
|
return None
|
||||||
# If all album IDs are equal, look up the album.
|
# If all album IDs are equal, look up the album.
|
||||||
log.debug('Searching for discovered album ID: {0}', first)
|
log.debug("Searching for discovered album ID: {0}", first)
|
||||||
return hooks.album_for_mbid(first)
|
return hooks.album_for_mbid(first)
|
||||||
|
|
||||||
|
|
||||||
def _recommendation(results):
|
def _recommendation(
|
||||||
|
results: Sequence[Union[AlbumMatch, TrackMatch]],
|
||||||
|
) -> Recommendation:
|
||||||
"""Given a sorted list of AlbumMatch or TrackMatch objects, return a
|
"""Given a sorted list of AlbumMatch or TrackMatch objects, return a
|
||||||
recommendation based on the results' distances.
|
recommendation based on the results' distances.
|
||||||
|
|
||||||
@@ -301,17 +366,19 @@ def _recommendation(results):
|
|||||||
|
|
||||||
# Basic distance thresholding.
|
# Basic distance thresholding.
|
||||||
min_dist = results[0].distance
|
min_dist = results[0].distance
|
||||||
if min_dist < config['match']['strong_rec_thresh'].as_number():
|
if min_dist < config["match"]["strong_rec_thresh"].as_number():
|
||||||
# Strong recommendation level.
|
# Strong recommendation level.
|
||||||
rec = Recommendation.strong
|
rec = Recommendation.strong
|
||||||
elif min_dist <= config['match']['medium_rec_thresh'].as_number():
|
elif min_dist <= config["match"]["medium_rec_thresh"].as_number():
|
||||||
# Medium recommendation level.
|
# Medium recommendation level.
|
||||||
rec = Recommendation.medium
|
rec = Recommendation.medium
|
||||||
elif len(results) == 1:
|
elif len(results) == 1:
|
||||||
# Only a single candidate.
|
# Only a single candidate.
|
||||||
rec = Recommendation.low
|
rec = Recommendation.low
|
||||||
elif results[1].distance - min_dist >= \
|
elif (
|
||||||
config['match']['rec_gap_thresh'].as_number():
|
results[1].distance - min_dist
|
||||||
|
>= config["match"]["rec_gap_thresh"].as_number()
|
||||||
|
):
|
||||||
# Gap between first two candidates is large.
|
# Gap between first two candidates is large.
|
||||||
rec = Recommendation.low
|
rec = Recommendation.low
|
||||||
else:
|
else:
|
||||||
@@ -324,48 +391,60 @@ def _recommendation(results):
|
|||||||
if isinstance(results[0], hooks.AlbumMatch):
|
if isinstance(results[0], hooks.AlbumMatch):
|
||||||
for track_dist in min_dist.tracks.values():
|
for track_dist in min_dist.tracks.values():
|
||||||
keys.update(list(track_dist.keys()))
|
keys.update(list(track_dist.keys()))
|
||||||
max_rec_view = config['match']['max_rec']
|
max_rec_view = config["match"]["max_rec"]
|
||||||
for key in keys:
|
for key in keys:
|
||||||
if key in list(max_rec_view.keys()):
|
if key in list(max_rec_view.keys()):
|
||||||
max_rec = max_rec_view[key].as_choice({
|
max_rec = max_rec_view[key].as_choice(
|
||||||
'strong': Recommendation.strong,
|
{
|
||||||
'medium': Recommendation.medium,
|
"strong": Recommendation.strong,
|
||||||
'low': Recommendation.low,
|
"medium": Recommendation.medium,
|
||||||
'none': Recommendation.none,
|
"low": Recommendation.low,
|
||||||
})
|
"none": Recommendation.none,
|
||||||
|
}
|
||||||
|
)
|
||||||
rec = min(rec, max_rec)
|
rec = min(rec, max_rec)
|
||||||
|
|
||||||
return rec
|
return rec
|
||||||
|
|
||||||
|
|
||||||
def _sort_candidates(candidates):
|
AnyMatch = TypeVar("AnyMatch", TrackMatch, AlbumMatch)
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_candidates(candidates: Iterable[AnyMatch]) -> Sequence[AnyMatch]:
|
||||||
"""Sort candidates by distance."""
|
"""Sort candidates by distance."""
|
||||||
return sorted(candidates, key=lambda match: match.distance)
|
return sorted(candidates, key=lambda match: match.distance)
|
||||||
|
|
||||||
|
|
||||||
def _add_candidate(items, results, info):
|
def _add_candidate(
|
||||||
|
items: Sequence[Item],
|
||||||
|
results: Dict[Any, AlbumMatch],
|
||||||
|
info: AlbumInfo,
|
||||||
|
):
|
||||||
"""Given a candidate AlbumInfo object, attempt to add the candidate
|
"""Given a candidate AlbumInfo object, attempt to add the candidate
|
||||||
to the output dictionary of AlbumMatch objects. This involves
|
to the output dictionary of AlbumMatch objects. This involves
|
||||||
checking the track count, ordering the items, checking for
|
checking the track count, ordering the items, checking for
|
||||||
duplicates, and calculating the distance.
|
duplicates, and calculating the distance.
|
||||||
"""
|
"""
|
||||||
log.debug('Candidate: {0} - {1} ({2})',
|
log.debug(
|
||||||
info.artist, info.album, info.album_id)
|
"Candidate: {0} - {1} ({2})", info.artist, info.album, info.album_id
|
||||||
|
)
|
||||||
|
|
||||||
# Discard albums with zero tracks.
|
# Discard albums with zero tracks.
|
||||||
if not info.tracks:
|
if not info.tracks:
|
||||||
log.debug('No tracks.')
|
log.debug("No tracks.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Don't duplicate.
|
# Prevent duplicates.
|
||||||
if info.album_id in results:
|
if info.album_id and info.album_id in results:
|
||||||
log.debug('Duplicate.')
|
log.debug("Duplicate.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Discard matches without required tags.
|
# Discard matches without required tags.
|
||||||
for req_tag in config['match']['required'].as_str_seq():
|
for req_tag in cast(
|
||||||
|
Sequence[str], config["match"]["required"].as_str_seq()
|
||||||
|
):
|
||||||
if getattr(info, req_tag) is None:
|
if getattr(info, req_tag) is None:
|
||||||
log.debug('Ignored. Missing required tag: {0}', req_tag)
|
log.debug("Ignored. Missing required tag: {0}", req_tag)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Find mapping between the items and the track info.
|
# Find mapping between the items and the track info.
|
||||||
@@ -376,18 +455,24 @@ def _add_candidate(items, results, info):
|
|||||||
|
|
||||||
# Skip matches with ignored penalties.
|
# Skip matches with ignored penalties.
|
||||||
penalties = [key for key, _ in dist]
|
penalties = [key for key, _ in dist]
|
||||||
for penalty in config['match']['ignored'].as_str_seq():
|
ignored = cast(Sequence[str], config["match"]["ignored"].as_str_seq())
|
||||||
|
for penalty in ignored:
|
||||||
if penalty in penalties:
|
if penalty in penalties:
|
||||||
log.debug('Ignored. Penalty: {0}', penalty)
|
log.debug("Ignored. Penalty: {0}", penalty)
|
||||||
return
|
return
|
||||||
|
|
||||||
log.debug('Success. Distance: {0}', dist)
|
log.debug("Success. Distance: {0}", dist)
|
||||||
results[info.album_id] = hooks.AlbumMatch(dist, info, mapping,
|
results[info.album_id] = hooks.AlbumMatch(
|
||||||
extra_items, extra_tracks)
|
dist, info, mapping, extra_items, extra_tracks
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def tag_album(items, search_artist=None, search_album=None,
|
def tag_album(
|
||||||
search_ids=[]):
|
items,
|
||||||
|
search_artist: Optional[str] = None,
|
||||||
|
search_album: Optional[str] = None,
|
||||||
|
search_ids: List[str] = [],
|
||||||
|
) -> Tuple[str, str, Proposal]:
|
||||||
"""Return a tuple of the current artist name, the current album
|
"""Return a tuple of the current artist name, the current album
|
||||||
name, and a `Proposal` containing `AlbumMatch` candidates.
|
name, and a `Proposal` containing `AlbumMatch` candidates.
|
||||||
|
|
||||||
@@ -407,20 +492,19 @@ def tag_album(items, search_artist=None, search_album=None,
|
|||||||
"""
|
"""
|
||||||
# Get current metadata.
|
# Get current metadata.
|
||||||
likelies, consensus = current_metadata(items)
|
likelies, consensus = current_metadata(items)
|
||||||
cur_artist = likelies['artist']
|
cur_artist = cast(str, likelies["artist"])
|
||||||
cur_album = likelies['album']
|
cur_album = cast(str, likelies["album"])
|
||||||
log.debug('Tagging {0} - {1}', cur_artist, cur_album)
|
log.debug("Tagging {0} - {1}", cur_artist, cur_album)
|
||||||
|
|
||||||
# The output result (distance, AlbumInfo) tuples (keyed by MB album
|
# The output result, keys are the MB album ID.
|
||||||
# ID).
|
candidates: Dict[Any, AlbumMatch] = {}
|
||||||
candidates = {}
|
|
||||||
|
|
||||||
# Search by explicit ID.
|
# Search by explicit ID.
|
||||||
if search_ids:
|
if search_ids:
|
||||||
for search_id in search_ids:
|
for search_id in search_ids:
|
||||||
log.debug('Searching for album ID: {0}', search_id)
|
log.debug("Searching for album ID: {0}", search_id)
|
||||||
for id_candidate in hooks.albums_for_id(search_id):
|
for album_info_for_id in hooks.albums_for_id(search_id):
|
||||||
_add_candidate(items, candidates, id_candidate)
|
_add_candidate(items, candidates, album_info_for_id)
|
||||||
|
|
||||||
# Use existing metadata or text search.
|
# Use existing metadata or text search.
|
||||||
else:
|
else:
|
||||||
@@ -429,51 +513,58 @@ def tag_album(items, search_artist=None, search_album=None,
|
|||||||
if id_info:
|
if id_info:
|
||||||
_add_candidate(items, candidates, id_info)
|
_add_candidate(items, candidates, id_info)
|
||||||
rec = _recommendation(list(candidates.values()))
|
rec = _recommendation(list(candidates.values()))
|
||||||
log.debug('Album ID match recommendation is {0}', rec)
|
log.debug("Album ID match recommendation is {0}", rec)
|
||||||
if candidates and not config['import']['timid']:
|
if candidates and not config["import"]["timid"]:
|
||||||
# If we have a very good MBID match, return immediately.
|
# If we have a very good MBID match, return immediately.
|
||||||
# Otherwise, this match will compete against metadata-based
|
# Otherwise, this match will compete against metadata-based
|
||||||
# matches.
|
# matches.
|
||||||
if rec == Recommendation.strong:
|
if rec == Recommendation.strong:
|
||||||
log.debug('ID match.')
|
log.debug("ID match.")
|
||||||
return cur_artist, cur_album, \
|
return (
|
||||||
Proposal(list(candidates.values()), rec)
|
cur_artist,
|
||||||
|
cur_album,
|
||||||
|
Proposal(list(candidates.values()), rec),
|
||||||
|
)
|
||||||
|
|
||||||
# Search terms.
|
# Search terms.
|
||||||
if not (search_artist and search_album):
|
if not (search_artist and search_album):
|
||||||
# No explicit search terms -- use current metadata.
|
# No explicit search terms -- use current metadata.
|
||||||
search_artist, search_album = cur_artist, cur_album
|
search_artist, search_album = cur_artist, cur_album
|
||||||
log.debug('Search terms: {0} - {1}', search_artist, search_album)
|
log.debug("Search terms: {0} - {1}", search_artist, search_album)
|
||||||
|
|
||||||
extra_tags = None
|
extra_tags = None
|
||||||
if config['musicbrainz']['extra_tags']:
|
if config["musicbrainz"]["extra_tags"]:
|
||||||
tag_list = config['musicbrainz']['extra_tags'].get()
|
tag_list = config["musicbrainz"]["extra_tags"].get()
|
||||||
extra_tags = {k: v for (k, v) in likelies.items() if k in tag_list}
|
extra_tags = {k: v for (k, v) in likelies.items() if k in tag_list}
|
||||||
log.debug('Additional search terms: {0}', extra_tags)
|
log.debug("Additional search terms: {0}", extra_tags)
|
||||||
|
|
||||||
# Is this album likely to be a "various artist" release?
|
# Is this album likely to be a "various artist" release?
|
||||||
va_likely = ((not consensus['artist']) or
|
va_likely = (
|
||||||
(search_artist.lower() in VA_ARTISTS) or
|
(not consensus["artist"])
|
||||||
any(item.comp for item in items))
|
or (search_artist.lower() in VA_ARTISTS)
|
||||||
log.debug('Album might be VA: {0}', va_likely)
|
or any(item.comp for item in items)
|
||||||
|
)
|
||||||
|
log.debug("Album might be VA: {0}", va_likely)
|
||||||
|
|
||||||
# Get the results from the data sources.
|
# Get the results from the data sources.
|
||||||
for matched_candidate in hooks.album_candidates(items,
|
for matched_candidate in hooks.album_candidates(
|
||||||
search_artist,
|
items, search_artist, search_album, va_likely, extra_tags
|
||||||
search_album,
|
):
|
||||||
va_likely,
|
|
||||||
extra_tags):
|
|
||||||
_add_candidate(items, candidates, matched_candidate)
|
_add_candidate(items, candidates, matched_candidate)
|
||||||
|
|
||||||
log.debug('Evaluating {0} candidates.', len(candidates))
|
log.debug("Evaluating {0} candidates.", len(candidates))
|
||||||
# Sort and get the recommendation.
|
# Sort and get the recommendation.
|
||||||
candidates = _sort_candidates(candidates.values())
|
candidates_sorted = _sort_candidates(candidates.values())
|
||||||
rec = _recommendation(candidates)
|
rec = _recommendation(candidates_sorted)
|
||||||
return cur_artist, cur_album, Proposal(candidates, rec)
|
return cur_artist, cur_album, Proposal(candidates_sorted, rec)
|
||||||
|
|
||||||
|
|
||||||
def tag_item(item, search_artist=None, search_title=None,
|
def tag_item(
|
||||||
search_ids=[]):
|
item,
|
||||||
|
search_artist: Optional[str] = None,
|
||||||
|
search_title: Optional[str] = None,
|
||||||
|
search_ids: Optional[List[str]] = None,
|
||||||
|
) -> Proposal:
|
||||||
"""Find metadata for a single track. Return a `Proposal` consisting
|
"""Find metadata for a single track. Return a `Proposal` consisting
|
||||||
of `TrackMatch` objects.
|
of `TrackMatch` objects.
|
||||||
|
|
||||||
@@ -485,26 +576,31 @@ def tag_item(item, search_artist=None, search_title=None,
|
|||||||
# Holds candidates found so far: keys are MBIDs; values are
|
# Holds candidates found so far: keys are MBIDs; values are
|
||||||
# (distance, TrackInfo) pairs.
|
# (distance, TrackInfo) pairs.
|
||||||
candidates = {}
|
candidates = {}
|
||||||
|
rec: Optional[Recommendation] = None
|
||||||
|
|
||||||
# First, try matching by MusicBrainz ID.
|
# First, try matching by MusicBrainz ID.
|
||||||
trackids = search_ids or [t for t in [item.mb_trackid] if t]
|
trackids = search_ids or [t for t in [item.mb_trackid] if t]
|
||||||
if trackids:
|
if trackids:
|
||||||
for trackid in trackids:
|
for trackid in trackids:
|
||||||
log.debug('Searching for track ID: {0}', trackid)
|
log.debug("Searching for track ID: {0}", trackid)
|
||||||
for track_info in hooks.tracks_for_id(trackid):
|
for track_info in hooks.tracks_for_id(trackid):
|
||||||
dist = track_distance(item, track_info, incl_artist=True)
|
dist = track_distance(item, track_info, incl_artist=True)
|
||||||
candidates[track_info.track_id] = \
|
candidates[track_info.track_id] = hooks.TrackMatch(
|
||||||
hooks.TrackMatch(dist, track_info)
|
dist, track_info
|
||||||
|
)
|
||||||
# If this is a good match, then don't keep searching.
|
# If this is a good match, then don't keep searching.
|
||||||
rec = _recommendation(_sort_candidates(candidates.values()))
|
rec = _recommendation(_sort_candidates(candidates.values()))
|
||||||
if rec == Recommendation.strong and \
|
if (
|
||||||
not config['import']['timid']:
|
rec == Recommendation.strong
|
||||||
log.debug('Track ID match.')
|
and not config["import"]["timid"]
|
||||||
|
):
|
||||||
|
log.debug("Track ID match.")
|
||||||
return Proposal(_sort_candidates(candidates.values()), rec)
|
return Proposal(_sort_candidates(candidates.values()), rec)
|
||||||
|
|
||||||
# If we're searching by ID, don't proceed.
|
# If we're searching by ID, don't proceed.
|
||||||
if search_ids:
|
if search_ids:
|
||||||
if candidates:
|
if candidates:
|
||||||
|
assert rec is not None
|
||||||
return Proposal(_sort_candidates(candidates.values()), rec)
|
return Proposal(_sort_candidates(candidates.values()), rec)
|
||||||
else:
|
else:
|
||||||
return Proposal([], Recommendation.none)
|
return Proposal([], Recommendation.none)
|
||||||
@@ -512,7 +608,7 @@ def tag_item(item, search_artist=None, search_title=None,
|
|||||||
# Search terms.
|
# Search terms.
|
||||||
if not (search_artist and search_title):
|
if not (search_artist and search_title):
|
||||||
search_artist, search_title = item.artist, item.title
|
search_artist, search_title = item.artist, item.title
|
||||||
log.debug('Item search terms: {0} - {1}', search_artist, search_title)
|
log.debug("Item search terms: {0} - {1}", search_artist, search_title)
|
||||||
|
|
||||||
# Get and evaluate candidate metadata.
|
# Get and evaluate candidate metadata.
|
||||||
for track_info in hooks.item_candidates(item, search_artist, search_title):
|
for track_info in hooks.item_candidates(item, search_artist, search_title):
|
||||||
@@ -520,7 +616,7 @@ def tag_item(item, search_artist=None, search_title=None,
|
|||||||
candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info)
|
candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info)
|
||||||
|
|
||||||
# Sort by distance and return with recommendation.
|
# Sort by distance and return with recommendation.
|
||||||
log.debug('Found {0} candidates.', len(candidates))
|
log.debug("Found {0} candidates.", len(candidates))
|
||||||
candidates = _sort_candidates(candidates.values())
|
candidates_sorted = _sort_candidates(candidates.values())
|
||||||
rec = _recommendation(candidates)
|
rec = _recommendation(candidates_sorted)
|
||||||
return Proposal(candidates, rec)
|
return Proposal(candidates_sorted, rec)
|
||||||
|
|||||||
+556
-251
File diff suppressed because it is too large
Load Diff
+114
-41
@@ -1,10 +1,34 @@
|
|||||||
|
# --------------- Main ---------------
|
||||||
|
|
||||||
library: library.db
|
library: library.db
|
||||||
directory: ~/Music
|
directory: ~/Music
|
||||||
|
statefile: state.pickle
|
||||||
|
|
||||||
|
# --------------- Plugins ---------------
|
||||||
|
|
||||||
|
plugins: []
|
||||||
|
pluginpath: []
|
||||||
|
|
||||||
|
# --------------- Import ---------------
|
||||||
|
|
||||||
|
clutter: ["Thumbs.DB", ".DS_Store"]
|
||||||
|
ignore: [".*", "*~", "System Volume Information", "lost+found"]
|
||||||
|
ignore_hidden: yes
|
||||||
|
|
||||||
import:
|
import:
|
||||||
|
# common options
|
||||||
write: yes
|
write: yes
|
||||||
copy: yes
|
copy: yes
|
||||||
move: no
|
move: no
|
||||||
|
timid: no
|
||||||
|
quiet: no
|
||||||
|
log:
|
||||||
|
# other options
|
||||||
|
default_action: apply
|
||||||
|
languages: []
|
||||||
|
quiet_fallback: skip
|
||||||
|
none_rec_action: ask
|
||||||
|
# rare options
|
||||||
link: no
|
link: no
|
||||||
hardlink: no
|
hardlink: no
|
||||||
reflink: no
|
reflink: no
|
||||||
@@ -13,76 +37,117 @@ import:
|
|||||||
incremental: no
|
incremental: no
|
||||||
incremental_skip_later: no
|
incremental_skip_later: no
|
||||||
from_scratch: no
|
from_scratch: no
|
||||||
quiet_fallback: skip
|
|
||||||
none_rec_action: ask
|
|
||||||
timid: no
|
|
||||||
log:
|
|
||||||
autotag: yes
|
autotag: yes
|
||||||
quiet: no
|
|
||||||
singletons: no
|
singletons: no
|
||||||
default_action: apply
|
|
||||||
languages: []
|
|
||||||
detail: no
|
detail: no
|
||||||
flat: no
|
flat: no
|
||||||
group_albums: no
|
group_albums: no
|
||||||
pretend: false
|
pretend: false
|
||||||
search_ids: []
|
search_ids: []
|
||||||
|
duplicate_keys:
|
||||||
|
album: albumartist album
|
||||||
|
item: artist title
|
||||||
duplicate_action: ask
|
duplicate_action: ask
|
||||||
|
duplicate_verbose_prompt: no
|
||||||
bell: no
|
bell: no
|
||||||
set_fields: {}
|
set_fields: {}
|
||||||
|
ignored_alias_types: []
|
||||||
|
singleton_album_disambig: yes
|
||||||
|
|
||||||
clutter: ["Thumbs.DB", ".DS_Store"]
|
# --------------- Paths ---------------
|
||||||
ignore: [".*", "*~", "System Volume Information", "lost+found"]
|
|
||||||
ignore_hidden: yes
|
|
||||||
|
|
||||||
replace:
|
|
||||||
'[\\/]': _
|
|
||||||
'^\.': _
|
|
||||||
'[\x00-\x1f]': _
|
|
||||||
'[<>:"\?\*\|]': _
|
|
||||||
'\.$': _
|
|
||||||
'\s+$': ''
|
|
||||||
'^\s+': ''
|
|
||||||
'^-': _
|
|
||||||
path_sep_replace: _
|
path_sep_replace: _
|
||||||
drive_sep_replace: _
|
drive_sep_replace: _
|
||||||
asciify_paths: false
|
asciify_paths: false
|
||||||
art_filename: cover
|
art_filename: cover
|
||||||
max_filename_length: 0
|
max_filename_length: 0
|
||||||
|
replace:
|
||||||
|
# Replace bad characters with _
|
||||||
|
# prohibited in many filesystem paths
|
||||||
|
'[<>:\?\*\|]': _
|
||||||
|
# double quotation mark "
|
||||||
|
'\"': _
|
||||||
|
# path separators: \ or /
|
||||||
|
'[\\/]': _
|
||||||
|
# starting and closing periods
|
||||||
|
'^\.': _
|
||||||
|
'\.$': _
|
||||||
|
# control characters
|
||||||
|
'[\x00-\x1f]': _
|
||||||
|
# dash at the start of a filename (causes command line ambiguity)
|
||||||
|
'^-': _
|
||||||
|
# Replace bad characters with nothing
|
||||||
|
# starting and closing whitespace
|
||||||
|
'\s+$': ''
|
||||||
|
'^\s+': ''
|
||||||
|
|
||||||
aunique:
|
aunique:
|
||||||
keys: albumartist album
|
keys: albumartist album
|
||||||
disambiguators: albumtype year label catalognum albumdisambig releasegroupdisambig
|
disambiguators: albumtype year label catalognum albumdisambig releasegroupdisambig
|
||||||
bracket: '[]'
|
bracket: '[]'
|
||||||
|
|
||||||
overwrite_null:
|
sunique:
|
||||||
album: []
|
keys: artist title
|
||||||
track: []
|
disambiguators: year trackdisambig
|
||||||
|
bracket: '[]'
|
||||||
|
|
||||||
|
# --------------- Tagging ---------------
|
||||||
|
|
||||||
plugins: []
|
|
||||||
pluginpath: []
|
|
||||||
threaded: yes
|
|
||||||
timeout: 5.0
|
|
||||||
per_disc_numbering: no
|
per_disc_numbering: no
|
||||||
verbose: 0
|
|
||||||
terminal_encoding:
|
|
||||||
original_date: no
|
original_date: no
|
||||||
artist_credit: no
|
artist_credit: no
|
||||||
id3v23: no
|
id3v23: no
|
||||||
va_name: "Various Artists"
|
va_name: "Various Artists"
|
||||||
|
paths:
|
||||||
|
default: $albumartist/$album%aunique{}/$track $title
|
||||||
|
singleton: Non-Album/$artist/$title
|
||||||
|
comp: Compilations/$album%aunique{}/$track $title
|
||||||
|
|
||||||
|
# --------------- Performance ---------------
|
||||||
|
|
||||||
|
threaded: yes
|
||||||
|
timeout: 5.0
|
||||||
|
|
||||||
|
# --------------- UI ---------------
|
||||||
|
|
||||||
|
verbose: 0
|
||||||
|
terminal_encoding:
|
||||||
|
|
||||||
ui:
|
ui:
|
||||||
terminal_width: 80
|
terminal_width: 80
|
||||||
length_diff_thresh: 10.0
|
length_diff_thresh: 10.0
|
||||||
color: yes
|
color: yes
|
||||||
colors:
|
colors:
|
||||||
text_success: green
|
text_success: ['bold', 'green']
|
||||||
text_warning: yellow
|
text_warning: ['bold', 'yellow']
|
||||||
text_error: red
|
text_error: ['bold', 'red']
|
||||||
text_highlight: red
|
text_highlight: ['bold', 'red']
|
||||||
text_highlight_minor: lightgray
|
text_highlight_minor: ['white']
|
||||||
action_default: turquoise
|
action_default: ['bold', 'cyan']
|
||||||
action: blue
|
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 ---------------
|
||||||
|
|
||||||
format_item: $artist - $album - $title
|
format_item: $artist - $album - $title
|
||||||
format_album: $albumartist - $album
|
format_album: $albumartist - $album
|
||||||
@@ -93,14 +158,13 @@ sort_album: albumartist+ album+
|
|||||||
sort_item: artist+ album+ disc+ track+
|
sort_item: artist+ album+ disc+ track+
|
||||||
sort_case_insensitive: yes
|
sort_case_insensitive: yes
|
||||||
|
|
||||||
paths:
|
# --------------- Autotagger ---------------
|
||||||
default: $albumartist/$album%aunique{}/$track $title
|
|
||||||
singleton: Non-Album/$artist/$title
|
|
||||||
comp: Compilations/$album%aunique{}/$track $title
|
|
||||||
|
|
||||||
statefile: state.pickle
|
|
||||||
|
|
||||||
|
overwrite_null:
|
||||||
|
album: []
|
||||||
|
track: []
|
||||||
musicbrainz:
|
musicbrainz:
|
||||||
|
enabled: yes
|
||||||
host: musicbrainz.org
|
host: musicbrainz.org
|
||||||
https: no
|
https: no
|
||||||
ratelimit: 1
|
ratelimit: 1
|
||||||
@@ -108,6 +172,13 @@ musicbrainz:
|
|||||||
searchlimit: 5
|
searchlimit: 5
|
||||||
extra_tags: []
|
extra_tags: []
|
||||||
genres: no
|
genres: no
|
||||||
|
external_ids:
|
||||||
|
discogs: no
|
||||||
|
bandcamp: no
|
||||||
|
spotify: no
|
||||||
|
deezer: no
|
||||||
|
beatport: no
|
||||||
|
tidal: no
|
||||||
|
|
||||||
match:
|
match:
|
||||||
strong_rec_thresh: 0.04
|
strong_rec_thresh: 0.04
|
||||||
@@ -147,3 +218,5 @@ match:
|
|||||||
ignore_video_tracks: yes
|
ignore_video_tracks: yes
|
||||||
track_length_grace: 10
|
track_length_grace: 10
|
||||||
track_length_max: 30
|
track_length_max: 30
|
||||||
|
album_disambig_fields: data_source media year country label catalognum albumdisambig
|
||||||
|
singleton_disambig_fields: data_source index track_alt album
|
||||||
|
|||||||
@@ -16,12 +16,20 @@
|
|||||||
Library.
|
Library.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .db import Model, Database
|
from .db import Database, Model, Results
|
||||||
from .query import Query, FieldQuery, MatchQuery, AndQuery, OrQuery
|
from .query import (
|
||||||
|
AndQuery,
|
||||||
|
FieldQuery,
|
||||||
|
InvalidQueryError,
|
||||||
|
MatchQuery,
|
||||||
|
OrQuery,
|
||||||
|
Query,
|
||||||
|
)
|
||||||
|
from .queryparse import (
|
||||||
|
parse_sorted_query,
|
||||||
|
query_from_strings,
|
||||||
|
sort_from_strings,
|
||||||
|
)
|
||||||
from .types import Type
|
from .types import Type
|
||||||
from .queryparse import query_from_strings
|
|
||||||
from .queryparse import sort_from_strings
|
|
||||||
from .queryparse import parse_sorted_query
|
|
||||||
from .query import InvalidQueryError
|
|
||||||
|
|
||||||
# flake8: noqa
|
# flake8: noqa
|
||||||
|
|||||||
+432
-238
File diff suppressed because it is too large
Load Diff
+431
-288
File diff suppressed because it is too large
Load Diff
@@ -12,30 +12,33 @@
|
|||||||
# The above copyright notice and this permission notice shall be
|
# The above copyright notice and this permission notice shall be
|
||||||
# included in all copies or substantial portions of the Software.
|
# included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
"""Parsing of strings into DBCore queries.
|
"""Parsing of strings into DBCore queries."""
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
import itertools
|
import itertools
|
||||||
from . import query
|
import re
|
||||||
|
from typing import Collection, Dict, List, Optional, Sequence, Tuple, Type
|
||||||
|
|
||||||
|
from . import Model, query
|
||||||
|
from .query import Sort
|
||||||
|
|
||||||
PARSE_QUERY_PART_REGEX = re.compile(
|
PARSE_QUERY_PART_REGEX = re.compile(
|
||||||
# Non-capturing optional segment for the keyword.
|
# Non-capturing optional segment for the keyword.
|
||||||
r'(-|\^)?' # Negation prefixes.
|
r"(-|\^)?" # Negation prefixes.
|
||||||
|
r"(?:"
|
||||||
r'(?:'
|
r"(\S+?)" # The field key.
|
||||||
r'(\S+?)' # The field key.
|
r"(?<!\\):" # Unescaped :
|
||||||
r'(?<!\\):' # Unescaped :
|
r")?"
|
||||||
r')?'
|
r"(.*)", # The term itself.
|
||||||
|
re.I, # Case-insensitive.
|
||||||
r'(.*)', # The term itself.
|
|
||||||
|
|
||||||
re.I # Case-insensitive.
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_query_part(part, query_classes={}, prefixes={},
|
def parse_query_part(
|
||||||
default_class=query.SubstringQuery):
|
part: str,
|
||||||
|
query_classes: Dict[str, Type[query.FieldQuery]] = {},
|
||||||
|
prefixes: Dict = {},
|
||||||
|
default_class: Type[query.SubstringQuery] = query.SubstringQuery,
|
||||||
|
) -> Tuple[Optional[str], str, Type[query.FieldQuery], bool]:
|
||||||
"""Parse a single *query part*, which is a chunk of a complete query
|
"""Parse a single *query part*, which is a chunk of a complete query
|
||||||
string representing a single criterion.
|
string representing a single criterion.
|
||||||
|
|
||||||
@@ -86,13 +89,13 @@ def parse_query_part(part, query_classes={}, prefixes={},
|
|||||||
assert match # Regex should always match
|
assert match # Regex should always match
|
||||||
negate = bool(match.group(1))
|
negate = bool(match.group(1))
|
||||||
key = match.group(2)
|
key = match.group(2)
|
||||||
term = match.group(3).replace('\\:', ':')
|
term = match.group(3).replace("\\:", ":")
|
||||||
|
|
||||||
# Check whether there's a prefix in the query and use the
|
# Check whether there's a prefix in the query and use the
|
||||||
# corresponding query type.
|
# corresponding query type.
|
||||||
for pre, query_class in prefixes.items():
|
for pre, query_class in prefixes.items():
|
||||||
if term.startswith(pre):
|
if term.startswith(pre):
|
||||||
return key, term[len(pre):], query_class, negate
|
return key, term[len(pre) :], query_class, negate
|
||||||
|
|
||||||
# No matching prefix, so use either the query class determined by
|
# No matching prefix, so use either the query class determined by
|
||||||
# the field or the default as a fallback.
|
# the field or the default as a fallback.
|
||||||
@@ -100,7 +103,11 @@ def parse_query_part(part, query_classes={}, prefixes={},
|
|||||||
return key, term, query_class, negate
|
return key, term, query_class, negate
|
||||||
|
|
||||||
|
|
||||||
def construct_query_part(model_cls, prefixes, query_part):
|
def construct_query_part(
|
||||||
|
model_cls: Type[Model],
|
||||||
|
prefixes: Dict,
|
||||||
|
query_part: str,
|
||||||
|
) -> query.Query:
|
||||||
"""Parse a *query part* string and return a :class:`Query` object.
|
"""Parse a *query part* string and return a :class:`Query` object.
|
||||||
|
|
||||||
:param model_cls: The :class:`Model` class that this is a query for.
|
:param model_cls: The :class:`Model` class that this is a query for.
|
||||||
@@ -116,40 +123,44 @@ def construct_query_part(model_cls, prefixes, query_part):
|
|||||||
if not query_part:
|
if not query_part:
|
||||||
return query.TrueQuery()
|
return query.TrueQuery()
|
||||||
|
|
||||||
|
out_query: query.Query
|
||||||
|
|
||||||
# Use `model_cls` to build up a map from field (or query) names to
|
# Use `model_cls` to build up a map from field (or query) names to
|
||||||
# `Query` classes.
|
# `Query` classes.
|
||||||
query_classes = {}
|
query_classes: Dict[str, Type[query.FieldQuery]] = {}
|
||||||
for k, t in itertools.chain(model_cls._fields.items(),
|
for k, t in itertools.chain(
|
||||||
model_cls._types.items()):
|
model_cls._fields.items(), model_cls._types.items()
|
||||||
|
):
|
||||||
query_classes[k] = t.query
|
query_classes[k] = t.query
|
||||||
query_classes.update(model_cls._queries) # Non-field queries.
|
query_classes.update(model_cls._queries) # Non-field queries.
|
||||||
|
|
||||||
# Parse the string.
|
# Parse the string.
|
||||||
key, pattern, query_class, negate = \
|
key, pattern, query_class, negate = parse_query_part(
|
||||||
parse_query_part(query_part, query_classes, prefixes)
|
query_part, query_classes, prefixes
|
||||||
|
)
|
||||||
|
|
||||||
# If there's no key (field name) specified, this is a "match
|
# If there's no key (field name) specified, this is a "match
|
||||||
# anything" query.
|
# anything" query.
|
||||||
if key is None:
|
if key is None:
|
||||||
if issubclass(query_class, query.FieldQuery):
|
# The query type matches a specific field, but none was
|
||||||
# The query type matches a specific field, but none was
|
# specified. So we use a version of the query that matches
|
||||||
# specified. So we use a version of the query that matches
|
# any field.
|
||||||
# any field.
|
out_query = query.AnyFieldQuery(
|
||||||
out_query = query.AnyFieldQuery(pattern, model_cls._search_fields,
|
pattern, model_cls._search_fields, query_class
|
||||||
query_class)
|
)
|
||||||
else:
|
|
||||||
# Non-field query type.
|
|
||||||
out_query = query_class(pattern)
|
|
||||||
|
|
||||||
# Field queries get constructed according to the name of the field
|
# Field queries get constructed according to the name of the field
|
||||||
# they are querying.
|
# they are querying.
|
||||||
elif issubclass(query_class, query.FieldQuery):
|
|
||||||
key = key.lower()
|
|
||||||
out_query = query_class(key.lower(), pattern, key in model_cls._fields)
|
|
||||||
|
|
||||||
# Non-field (named) query.
|
|
||||||
else:
|
else:
|
||||||
out_query = query_class(pattern)
|
field = table = key.lower()
|
||||||
|
if field in model_cls.shared_db_fields:
|
||||||
|
# This field exists in both tables, so SQLite will encounter
|
||||||
|
# an OperationalError if we try to query it in a join.
|
||||||
|
# Using an explicit table name resolves this.
|
||||||
|
table = f"{model_cls._table}.{field}"
|
||||||
|
|
||||||
|
field_in_db = field in model_cls.all_db_fields
|
||||||
|
out_query = query_class(table, pattern, field_in_db)
|
||||||
|
|
||||||
# Apply negation.
|
# Apply negation.
|
||||||
if negate:
|
if negate:
|
||||||
@@ -158,7 +169,13 @@ def construct_query_part(model_cls, prefixes, query_part):
|
|||||||
return out_query
|
return out_query
|
||||||
|
|
||||||
|
|
||||||
def query_from_strings(query_cls, model_cls, prefixes, query_parts):
|
# TYPING ERROR
|
||||||
|
def query_from_strings(
|
||||||
|
query_cls: Type[query.CollectionQuery],
|
||||||
|
model_cls: Type[Model],
|
||||||
|
prefixes: Dict,
|
||||||
|
query_parts: Collection[str],
|
||||||
|
) -> query.Query:
|
||||||
"""Creates a collection query of type `query_cls` from a list of
|
"""Creates a collection query of type `query_cls` from a list of
|
||||||
strings in the format used by parse_query_part. `model_cls`
|
strings in the format used by parse_query_part. `model_cls`
|
||||||
determines how queries are constructed from strings.
|
determines how queries are constructed from strings.
|
||||||
@@ -171,7 +188,11 @@ def query_from_strings(query_cls, model_cls, prefixes, query_parts):
|
|||||||
return query_cls(subqueries)
|
return query_cls(subqueries)
|
||||||
|
|
||||||
|
|
||||||
def construct_sort_part(model_cls, part, case_insensitive=True):
|
def construct_sort_part(
|
||||||
|
model_cls: Type[Model],
|
||||||
|
part: str,
|
||||||
|
case_insensitive: bool = True,
|
||||||
|
) -> Sort:
|
||||||
"""Create a `Sort` from a single string criterion.
|
"""Create a `Sort` from a single string criterion.
|
||||||
|
|
||||||
`model_cls` is the `Model` being queried. `part` is a single string
|
`model_cls` is the `Model` being queried. `part` is a single string
|
||||||
@@ -183,12 +204,13 @@ def construct_sort_part(model_cls, part, case_insensitive=True):
|
|||||||
field = part[:-1]
|
field = part[:-1]
|
||||||
assert field, "field is missing"
|
assert field, "field is missing"
|
||||||
direction = part[-1]
|
direction = part[-1]
|
||||||
assert direction in ('+', '-'), "part must end with + or -"
|
assert direction in ("+", "-"), "part must end with + or -"
|
||||||
is_ascending = direction == '+'
|
is_ascending = direction == "+"
|
||||||
|
|
||||||
if field in model_cls._sorts:
|
if field in model_cls._sorts:
|
||||||
sort = model_cls._sorts[field](model_cls, is_ascending,
|
sort = model_cls._sorts[field](
|
||||||
case_insensitive)
|
model_cls, is_ascending, case_insensitive
|
||||||
|
)
|
||||||
elif field in model_cls._fields:
|
elif field in model_cls._fields:
|
||||||
sort = query.FixedFieldSort(field, is_ascending, case_insensitive)
|
sort = query.FixedFieldSort(field, is_ascending, case_insensitive)
|
||||||
else:
|
else:
|
||||||
@@ -197,23 +219,31 @@ def construct_sort_part(model_cls, part, case_insensitive=True):
|
|||||||
return sort
|
return sort
|
||||||
|
|
||||||
|
|
||||||
def sort_from_strings(model_cls, sort_parts, case_insensitive=True):
|
def sort_from_strings(
|
||||||
"""Create a `Sort` from a list of sort criteria (strings).
|
model_cls: Type[Model],
|
||||||
"""
|
sort_parts: Sequence[str],
|
||||||
|
case_insensitive: bool = True,
|
||||||
|
) -> Sort:
|
||||||
|
"""Create a `Sort` from a list of sort criteria (strings)."""
|
||||||
if not sort_parts:
|
if not sort_parts:
|
||||||
sort = query.NullSort()
|
return query.NullSort()
|
||||||
elif len(sort_parts) == 1:
|
elif len(sort_parts) == 1:
|
||||||
sort = construct_sort_part(model_cls, sort_parts[0], case_insensitive)
|
return construct_sort_part(model_cls, sort_parts[0], case_insensitive)
|
||||||
else:
|
else:
|
||||||
sort = query.MultipleSort()
|
sort = query.MultipleSort()
|
||||||
for part in sort_parts:
|
for part in sort_parts:
|
||||||
sort.add_sort(construct_sort_part(model_cls, part,
|
sort.add_sort(
|
||||||
case_insensitive))
|
construct_sort_part(model_cls, part, case_insensitive)
|
||||||
return sort
|
)
|
||||||
|
return sort
|
||||||
|
|
||||||
|
|
||||||
def parse_sorted_query(model_cls, parts, prefixes={},
|
def parse_sorted_query(
|
||||||
case_insensitive=True):
|
model_cls: Type[Model],
|
||||||
|
parts: List[str],
|
||||||
|
prefixes: Dict = {},
|
||||||
|
case_insensitive: bool = True,
|
||||||
|
) -> Tuple[query.Query, Sort]:
|
||||||
"""Given a list of strings, create the `Query` and `Sort` that they
|
"""Given a list of strings, create the `Query` and `Sort` that they
|
||||||
represent.
|
represent.
|
||||||
"""
|
"""
|
||||||
@@ -224,24 +254,24 @@ def parse_sorted_query(model_cls, parts, prefixes={},
|
|||||||
# Split up query in to comma-separated subqueries, each representing
|
# Split up query in to comma-separated subqueries, each representing
|
||||||
# an AndQuery, which need to be joined together in one OrQuery
|
# an AndQuery, which need to be joined together in one OrQuery
|
||||||
subquery_parts = []
|
subquery_parts = []
|
||||||
for part in parts + [',']:
|
for part in parts + [","]:
|
||||||
if part.endswith(','):
|
if part.endswith(","):
|
||||||
# Ensure we can catch "foo, bar" as well as "foo , bar"
|
# Ensure we can catch "foo, bar" as well as "foo , bar"
|
||||||
last_subquery_part = part[:-1]
|
last_subquery_part = part[:-1]
|
||||||
if last_subquery_part:
|
if last_subquery_part:
|
||||||
subquery_parts.append(last_subquery_part)
|
subquery_parts.append(last_subquery_part)
|
||||||
# Parse the subquery in to a single AndQuery
|
# Parse the subquery in to a single AndQuery
|
||||||
# TODO: Avoid needlessly wrapping AndQueries containing 1 subquery?
|
# TODO: Avoid needlessly wrapping AndQueries containing 1 subquery?
|
||||||
query_parts.append(query_from_strings(
|
query_parts.append(
|
||||||
query.AndQuery, model_cls, prefixes, subquery_parts
|
query_from_strings(
|
||||||
))
|
query.AndQuery, model_cls, prefixes, subquery_parts
|
||||||
|
)
|
||||||
|
)
|
||||||
del subquery_parts[:]
|
del subquery_parts[:]
|
||||||
else:
|
else:
|
||||||
# Sort parts (1) end in + or -, (2) don't have a field, and
|
# Sort parts (1) end in + or -, (2) don't have a field, and
|
||||||
# (3) consist of more than just the + or -.
|
# (3) consist of more than just the + or -.
|
||||||
if part.endswith(('+', '-')) \
|
if part.endswith(("+", "-")) and ":" not in part and len(part) > 1:
|
||||||
and ':' not in part \
|
|
||||||
and len(part) > 1:
|
|
||||||
sort_parts.append(part)
|
sort_parts.append(part)
|
||||||
else:
|
else:
|
||||||
subquery_parts.append(part)
|
subquery_parts.append(part)
|
||||||
|
|||||||
+162
-67
@@ -14,28 +14,46 @@
|
|||||||
|
|
||||||
"""Representation of type information for DBCore model fields.
|
"""Representation of type information for DBCore model fields.
|
||||||
"""
|
"""
|
||||||
|
import typing
|
||||||
|
from abc import ABC
|
||||||
|
from typing import Any, Generic, List, TypeVar, Union, cast
|
||||||
|
|
||||||
from . import query
|
|
||||||
from beets.util import str2bool
|
from beets.util import str2bool
|
||||||
|
|
||||||
|
from .query import BooleanQuery, FieldQuery, NumericQuery, SubstringQuery
|
||||||
|
|
||||||
# Abstract base.
|
|
||||||
|
|
||||||
class Type:
|
class ModelType(typing.Protocol):
|
||||||
|
"""Protocol that specifies the required constructor for model types,
|
||||||
|
i.e. a function that takes any argument and attempts to parse it to the
|
||||||
|
given type.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, value: Any = None): ...
|
||||||
|
|
||||||
|
|
||||||
|
# Generic type variables, used for the value type T and null type N (if
|
||||||
|
# nullable, else T and N are set to the same type for the concrete subclasses
|
||||||
|
# of Type).
|
||||||
|
N = TypeVar("N")
|
||||||
|
T = TypeVar("T", bound=ModelType)
|
||||||
|
|
||||||
|
|
||||||
|
class Type(ABC, Generic[T, N]):
|
||||||
"""An object encapsulating the type of a model field. Includes
|
"""An object encapsulating the type of a model field. Includes
|
||||||
information about how to store, query, format, and parse a given
|
information about how to store, query, format, and parse a given
|
||||||
field.
|
field.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
sql = 'TEXT'
|
sql: str = "TEXT"
|
||||||
"""The SQLite column type for the value.
|
"""The SQLite column type for the value.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
query = query.SubstringQuery
|
query: typing.Type[FieldQuery] = SubstringQuery
|
||||||
"""The `Query` subclass to be used when querying the field.
|
"""The `Query` subclass to be used when querying the field.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_type = str
|
model_type: typing.Type[T]
|
||||||
"""The Python type that is used to represent the value in the model.
|
"""The Python type that is used to represent the value in the model.
|
||||||
|
|
||||||
The model is guaranteed to return a value of this type if the field
|
The model is guaranteed to return a value of this type if the field
|
||||||
@@ -44,12 +62,14 @@ class Type:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def null(self):
|
def null(self) -> N:
|
||||||
"""The value to be exposed when the underlying value is None.
|
"""The value to be exposed when the underlying value is None."""
|
||||||
"""
|
# Note that this default implementation only makes sense for T = N.
|
||||||
return self.model_type()
|
# It would be better to implement `null()` only in subclasses, or
|
||||||
|
# have a field null_type similar to `model_type` and use that here.
|
||||||
|
return cast(N, self.model_type())
|
||||||
|
|
||||||
def format(self, value):
|
def format(self, value: Union[N, T]) -> str:
|
||||||
"""Given a value of this type, produce a Unicode string
|
"""Given a value of this type, produce a Unicode string
|
||||||
representing the value. This is used in template evaluation.
|
representing the value. This is used in template evaluation.
|
||||||
"""
|
"""
|
||||||
@@ -57,13 +77,13 @@ class Type:
|
|||||||
value = self.null
|
value = self.null
|
||||||
# `self.null` might be `None`
|
# `self.null` might be `None`
|
||||||
if value is None:
|
if value is None:
|
||||||
value = ''
|
return ""
|
||||||
if isinstance(value, bytes):
|
elif isinstance(value, bytes):
|
||||||
value = value.decode('utf-8', 'ignore')
|
return value.decode("utf-8", "ignore")
|
||||||
|
else:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
return str(value)
|
def parse(self, string: str) -> Union[T, N]:
|
||||||
|
|
||||||
def parse(self, string):
|
|
||||||
"""Parse a (possibly human-written) string and return the
|
"""Parse a (possibly human-written) string and return the
|
||||||
indicated value of this type.
|
indicated value of this type.
|
||||||
"""
|
"""
|
||||||
@@ -72,19 +92,23 @@ class Type:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return self.null
|
return self.null
|
||||||
|
|
||||||
def normalize(self, value):
|
def normalize(self, value: Any) -> Union[T, N]:
|
||||||
"""Given a value that will be assigned into a field of this
|
"""Given a value that will be assigned into a field of this
|
||||||
type, normalize the value to have the appropriate type. This
|
type, normalize the value to have the appropriate type. This
|
||||||
base implementation only reinterprets `None`.
|
base implementation only reinterprets `None`.
|
||||||
"""
|
"""
|
||||||
|
# TYPING ERROR
|
||||||
if value is None:
|
if value is None:
|
||||||
return self.null
|
return self.null
|
||||||
else:
|
else:
|
||||||
# TODO This should eventually be replaced by
|
# TODO This should eventually be replaced by
|
||||||
# `self.model_type(value)`
|
# `self.model_type(value)`
|
||||||
return value
|
return cast(T, value)
|
||||||
|
|
||||||
def from_sql(self, sql_value):
|
def from_sql(
|
||||||
|
self,
|
||||||
|
sql_value: Union[None, int, float, str, bytes],
|
||||||
|
) -> Union[T, N]:
|
||||||
"""Receives the value stored in the SQL backend and return the
|
"""Receives the value stored in the SQL backend and return the
|
||||||
value to be stored in the model.
|
value to be stored in the model.
|
||||||
|
|
||||||
@@ -99,13 +123,13 @@ class Type:
|
|||||||
and the method must handle these in addition.
|
and the method must handle these in addition.
|
||||||
"""
|
"""
|
||||||
if isinstance(sql_value, memoryview):
|
if isinstance(sql_value, memoryview):
|
||||||
sql_value = bytes(sql_value).decode('utf-8', 'ignore')
|
sql_value = bytes(sql_value).decode("utf-8", "ignore")
|
||||||
if isinstance(sql_value, str):
|
if isinstance(sql_value, str):
|
||||||
return self.parse(sql_value)
|
return self.parse(sql_value)
|
||||||
else:
|
else:
|
||||||
return self.normalize(sql_value)
|
return self.normalize(sql_value)
|
||||||
|
|
||||||
def to_sql(self, model_value):
|
def to_sql(self, model_value: Any) -> Union[None, int, float, str, bytes]:
|
||||||
"""Convert a value as stored in the model object to a value used
|
"""Convert a value as stored in the model object to a value used
|
||||||
by the database adapter.
|
by the database adapter.
|
||||||
"""
|
"""
|
||||||
@@ -114,18 +138,23 @@ class Type:
|
|||||||
|
|
||||||
# Reusable types.
|
# Reusable types.
|
||||||
|
|
||||||
class Default(Type):
|
|
||||||
null = None
|
class Default(Type[str, None]):
|
||||||
|
model_type = str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def null(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class Integer(Type):
|
class BaseInteger(Type[int, N]):
|
||||||
"""A basic integer type.
|
"""A basic integer type."""
|
||||||
"""
|
|
||||||
sql = 'INTEGER'
|
sql = "INTEGER"
|
||||||
query = query.NumericQuery
|
query = NumericQuery
|
||||||
model_type = int
|
model_type = int
|
||||||
|
|
||||||
def normalize(self, value):
|
def normalize(self, value: Any) -> Union[int, N]:
|
||||||
try:
|
try:
|
||||||
return self.model_type(round(float(value)))
|
return self.model_type(round(float(value)))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -134,91 +163,153 @@ class Integer(Type):
|
|||||||
return self.null
|
return self.null
|
||||||
|
|
||||||
|
|
||||||
class PaddedInt(Integer):
|
class Integer(BaseInteger[int]):
|
||||||
|
@property
|
||||||
|
def null(self) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class NullInteger(BaseInteger[None]):
|
||||||
|
@property
|
||||||
|
def null(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class BasePaddedInt(BaseInteger[N]):
|
||||||
"""An integer field that is formatted with a given number of digits,
|
"""An integer field that is formatted with a given number of digits,
|
||||||
padded with zeroes.
|
padded with zeroes.
|
||||||
"""
|
"""
|
||||||
def __init__(self, digits):
|
|
||||||
|
def __init__(self, digits: int):
|
||||||
self.digits = digits
|
self.digits = digits
|
||||||
|
|
||||||
def format(self, value):
|
def format(self, value: Union[int, N]) -> str:
|
||||||
return '{0:0{1}d}'.format(value or 0, self.digits)
|
return "{0:0{1}d}".format(value or 0, self.digits)
|
||||||
|
|
||||||
|
|
||||||
class NullPaddedInt(PaddedInt):
|
class PaddedInt(BasePaddedInt[int]):
|
||||||
"""Same as `PaddedInt`, but does not normalize `None` to `0.0`.
|
pass
|
||||||
"""
|
|
||||||
null = None
|
|
||||||
|
class NullPaddedInt(BasePaddedInt[None]):
|
||||||
|
"""Same as `PaddedInt`, but does not normalize `None` to `0`."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def null(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class ScaledInt(Integer):
|
class ScaledInt(Integer):
|
||||||
"""An integer whose formatting operation scales the number by a
|
"""An integer whose formatting operation scales the number by a
|
||||||
constant and adds a suffix. Good for units with large magnitudes.
|
constant and adds a suffix. Good for units with large magnitudes.
|
||||||
"""
|
"""
|
||||||
def __init__(self, unit, suffix=''):
|
|
||||||
|
def __init__(self, unit: int, suffix: str = ""):
|
||||||
self.unit = unit
|
self.unit = unit
|
||||||
self.suffix = suffix
|
self.suffix = suffix
|
||||||
|
|
||||||
def format(self, value):
|
def format(self, value: int) -> str:
|
||||||
return '{}{}'.format((value or 0) // self.unit, self.suffix)
|
return "{}{}".format((value or 0) // self.unit, self.suffix)
|
||||||
|
|
||||||
|
|
||||||
class Id(Integer):
|
class Id(NullInteger):
|
||||||
"""An integer used as the row id or a foreign key in a SQLite table.
|
"""An integer used as the row id or a foreign key in a SQLite table.
|
||||||
This type is nullable: None values are not translated to zero.
|
This type is nullable: None values are not translated to zero.
|
||||||
"""
|
"""
|
||||||
null = None
|
|
||||||
|
|
||||||
def __init__(self, primary=True):
|
@property
|
||||||
|
def null(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __init__(self, primary: bool = True):
|
||||||
if primary:
|
if primary:
|
||||||
self.sql = 'INTEGER PRIMARY KEY'
|
self.sql = "INTEGER PRIMARY KEY"
|
||||||
|
|
||||||
|
|
||||||
class Float(Type):
|
class BaseFloat(Type[float, N]):
|
||||||
"""A basic floating-point type. The `digits` parameter specifies how
|
"""A basic floating-point type. The `digits` parameter specifies how
|
||||||
many decimal places to use in the human-readable representation.
|
many decimal places to use in the human-readable representation.
|
||||||
"""
|
"""
|
||||||
sql = 'REAL'
|
|
||||||
query = query.NumericQuery
|
sql = "REAL"
|
||||||
|
query: typing.Type[FieldQuery[Any]] = NumericQuery
|
||||||
model_type = float
|
model_type = float
|
||||||
|
|
||||||
def __init__(self, digits=1):
|
def __init__(self, digits: int = 1):
|
||||||
self.digits = digits
|
self.digits = digits
|
||||||
|
|
||||||
def format(self, value):
|
def format(self, value: Union[float, N]) -> str:
|
||||||
return '{0:.{1}f}'.format(value or 0, self.digits)
|
return "{0:.{1}f}".format(value or 0, self.digits)
|
||||||
|
|
||||||
|
|
||||||
class NullFloat(Float):
|
class Float(BaseFloat[float]):
|
||||||
"""Same as `Float`, but does not normalize `None` to `0.0`.
|
"""Floating-point type that normalizes `None` to `0.0`."""
|
||||||
"""
|
|
||||||
null = None
|
@property
|
||||||
|
def null(self) -> float:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
class String(Type):
|
class NullFloat(BaseFloat[None]):
|
||||||
"""A Unicode string type.
|
"""Same as `Float`, but does not normalize `None` to `0.0`."""
|
||||||
"""
|
|
||||||
sql = 'TEXT'
|
|
||||||
query = query.SubstringQuery
|
|
||||||
|
|
||||||
def normalize(self, value):
|
@property
|
||||||
|
def null(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class BaseString(Type[T, N]):
|
||||||
|
"""A Unicode string type."""
|
||||||
|
|
||||||
|
sql = "TEXT"
|
||||||
|
query = SubstringQuery
|
||||||
|
|
||||||
|
def normalize(self, value: Any) -> Union[T, N]:
|
||||||
if value is None:
|
if value is None:
|
||||||
return self.null
|
return self.null
|
||||||
else:
|
else:
|
||||||
return self.model_type(value)
|
return self.model_type(value)
|
||||||
|
|
||||||
|
|
||||||
class Boolean(Type):
|
class String(BaseString[str, Any]):
|
||||||
"""A boolean type.
|
"""A Unicode string type."""
|
||||||
|
|
||||||
|
model_type = str
|
||||||
|
|
||||||
|
|
||||||
|
class DelimitedString(BaseString[List[str], List[str]]):
|
||||||
|
"""A list of Unicode strings, represented in-database by a single string
|
||||||
|
containing delimiter-separated values.
|
||||||
"""
|
"""
|
||||||
sql = 'INTEGER'
|
|
||||||
query = query.BooleanQuery
|
model_type = list
|
||||||
|
|
||||||
|
def __init__(self, delimiter: str):
|
||||||
|
self.delimiter = delimiter
|
||||||
|
|
||||||
|
def format(self, value: List[str]):
|
||||||
|
return self.delimiter.join(value)
|
||||||
|
|
||||||
|
def parse(self, string: str):
|
||||||
|
if not string:
|
||||||
|
return []
|
||||||
|
return string.split(self.delimiter)
|
||||||
|
|
||||||
|
def to_sql(self, model_value: List[str]):
|
||||||
|
return self.delimiter.join(model_value)
|
||||||
|
|
||||||
|
|
||||||
|
class Boolean(Type):
|
||||||
|
"""A boolean type."""
|
||||||
|
|
||||||
|
sql = "INTEGER"
|
||||||
|
query = BooleanQuery
|
||||||
model_type = bool
|
model_type = bool
|
||||||
|
|
||||||
def format(self, value):
|
def format(self, value: bool) -> str:
|
||||||
return str(bool(value))
|
return str(bool(value))
|
||||||
|
|
||||||
def parse(self, string):
|
def parse(self, string: str) -> bool:
|
||||||
return str2bool(string)
|
return str2bool(string)
|
||||||
|
|
||||||
|
|
||||||
@@ -231,3 +322,7 @@ FLOAT = Float()
|
|||||||
NULL_FLOAT = NullFloat()
|
NULL_FLOAT = NullFloat()
|
||||||
STRING = String()
|
STRING = String()
|
||||||
BOOLEAN = Boolean()
|
BOOLEAN = Boolean()
|
||||||
|
SEMICOLON_SPACE_DSV = DelimitedString(delimiter="; ")
|
||||||
|
|
||||||
|
# Will set the proper null char in mediafile
|
||||||
|
MULTI_VALUE_DSV = DelimitedString(delimiter="\\␀")
|
||||||
|
|||||||
+445
-320
File diff suppressed because it is too large
Load Diff
+826
-547
File diff suppressed because it is too large
Load Diff
+59
-47
@@ -12,63 +12,50 @@
|
|||||||
# The above copyright notice and this permission notice shall be
|
# The above copyright notice and this permission notice shall be
|
||||||
# included in all copies or substantial portions of the Software.
|
# included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
"""A drop-in replacement for the standard-library `logging` module that
|
"""A drop-in replacement for the standard-library `logging` module.
|
||||||
allows {}-style log formatting on Python 2 and 3.
|
|
||||||
|
|
||||||
Provides everything the "logging" module does. The only difference is
|
Provides everything the "logging" module does. In addition, beets' logger
|
||||||
that when getLogger(name) instantiates a logger that logger uses
|
(as obtained by `getLogger(name)`) supports thread-local levels, and messages
|
||||||
{}-style formatting.
|
use {}-style formatting and can interpolate keywords arguments to the logging
|
||||||
|
calls (`debug`, `info`, etc).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
from copy import copy
|
import logging
|
||||||
from logging import * # noqa
|
|
||||||
import subprocess
|
|
||||||
import threading
|
import threading
|
||||||
|
from copy import copy
|
||||||
|
|
||||||
|
|
||||||
def logsafe(val):
|
def logsafe(val):
|
||||||
"""Coerce a potentially "problematic" value so it can be formatted
|
"""Coerce `bytes` to `str` to avoid crashes solely due to logging.
|
||||||
in a Unicode log string.
|
|
||||||
|
|
||||||
This works around a number of pitfalls when logging objects in
|
This is particularly relevant for bytestring paths. Much of our code
|
||||||
Python 2:
|
explicitly uses `displayable_path` for them, but better be safe and prevent
|
||||||
- Logging path names, which must be byte strings, requires
|
any crashes that are solely due to log formatting.
|
||||||
conversion for output.
|
|
||||||
- Some objects, including some exceptions, will crash when you call
|
|
||||||
`unicode(v)` while `str(v)` works fine. CalledProcessError is an
|
|
||||||
example.
|
|
||||||
"""
|
"""
|
||||||
# Already Unicode.
|
# Bytestring: Needs decoding to be safe for substitution in format strings.
|
||||||
if isinstance(val, str):
|
if isinstance(val, bytes):
|
||||||
return val
|
|
||||||
|
|
||||||
# Bytestring: needs decoding.
|
|
||||||
elif isinstance(val, bytes):
|
|
||||||
# Blindly convert with UTF-8. Eventually, it would be nice to
|
# Blindly convert with UTF-8. Eventually, it would be nice to
|
||||||
# (a) only do this for paths, if they can be given a distinct
|
# (a) only do this for paths, if they can be given a distinct
|
||||||
# type, and (b) warn the developer if they do this for other
|
# type, and (b) warn the developer if they do this for other
|
||||||
# bytestrings.
|
# bytestrings.
|
||||||
return val.decode('utf-8', 'replace')
|
return val.decode("utf-8", "replace")
|
||||||
|
|
||||||
# A "problem" object: needs a workaround.
|
|
||||||
elif isinstance(val, subprocess.CalledProcessError):
|
|
||||||
try:
|
|
||||||
return str(val)
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
# An object with a broken __unicode__ formatter. Use __str__
|
|
||||||
# instead.
|
|
||||||
return str(val).decode('utf-8', 'replace')
|
|
||||||
|
|
||||||
# Other objects are used as-is so field access, etc., still works in
|
# Other objects are used as-is so field access, etc., still works in
|
||||||
# the format string.
|
# the format string. Relies on a working __str__ implementation.
|
||||||
else:
|
return val
|
||||||
return val
|
|
||||||
|
|
||||||
|
|
||||||
class StrFormatLogger(Logger):
|
class StrFormatLogger(logging.Logger):
|
||||||
"""A version of `Logger` that uses `str.format`-style formatting
|
"""A version of `Logger` that uses `str.format`-style formatting
|
||||||
instead of %-style formatting.
|
instead of %-style formatting and supports keyword arguments.
|
||||||
|
|
||||||
|
We cannot easily get rid of this even in the Python 3 era: This custom
|
||||||
|
formatting supports substitution from `kwargs` into the message, which the
|
||||||
|
default `logging.Logger._log()` implementation does not.
|
||||||
|
|
||||||
|
Remark by @sampsyo: https://stackoverflow.com/a/24683360 might be a way to
|
||||||
|
achieve this with less code.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class _LogMessage:
|
class _LogMessage:
|
||||||
@@ -82,19 +69,39 @@ class StrFormatLogger(Logger):
|
|||||||
kwargs = {k: logsafe(v) for (k, v) in self.kwargs.items()}
|
kwargs = {k: logsafe(v) for (k, v) in self.kwargs.items()}
|
||||||
return self.msg.format(*args, **kwargs)
|
return self.msg.format(*args, **kwargs)
|
||||||
|
|
||||||
def _log(self, level, msg, args, exc_info=None, extra=None, **kwargs):
|
def _log(
|
||||||
|
self,
|
||||||
|
level,
|
||||||
|
msg,
|
||||||
|
args,
|
||||||
|
exc_info=None,
|
||||||
|
extra=None,
|
||||||
|
stack_info=False,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
"""Log msg.format(*args, **kwargs)"""
|
"""Log msg.format(*args, **kwargs)"""
|
||||||
m = self._LogMessage(msg, args, kwargs)
|
m = self._LogMessage(msg, args, kwargs)
|
||||||
return super()._log(level, m, (), exc_info, extra)
|
|
||||||
|
stacklevel = kwargs.pop("stacklevel", 1)
|
||||||
|
stacklevel = {"stacklevel": stacklevel}
|
||||||
|
|
||||||
|
return super()._log(
|
||||||
|
level,
|
||||||
|
m,
|
||||||
|
(),
|
||||||
|
exc_info=exc_info,
|
||||||
|
extra=extra,
|
||||||
|
stack_info=stack_info,
|
||||||
|
**stacklevel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ThreadLocalLevelLogger(Logger):
|
class ThreadLocalLevelLogger(logging.Logger):
|
||||||
"""A version of `Logger` whose level is thread-local instead of shared.
|
"""A version of `Logger` whose level is thread-local instead of shared."""
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, name, level=NOTSET):
|
def __init__(self, name, level=logging.NOTSET):
|
||||||
self._thread_level = threading.local()
|
self._thread_level = threading.local()
|
||||||
self.default_level = NOTSET
|
self.default_level = logging.NOTSET
|
||||||
super().__init__(name, level)
|
super().__init__(name, level)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -121,12 +128,17 @@ class BeetsLogger(ThreadLocalLevelLogger, StrFormatLogger):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
my_manager = copy(Logger.manager)
|
my_manager = copy(logging.Logger.manager)
|
||||||
my_manager.loggerClass = BeetsLogger
|
my_manager.loggerClass = BeetsLogger
|
||||||
|
|
||||||
|
|
||||||
|
# Act like the stdlib logging module by re-exporting its namespace.
|
||||||
|
from logging import * # noqa
|
||||||
|
|
||||||
|
|
||||||
|
# Override the `getLogger` to use our machinery.
|
||||||
def getLogger(name=None): # noqa
|
def getLogger(name=None): # noqa
|
||||||
if name:
|
if name:
|
||||||
return my_manager.getLogger(name)
|
return my_manager.getLogger(name)
|
||||||
else:
|
else:
|
||||||
return Logger.root
|
return logging.Logger.root
|
||||||
|
|||||||
+11
-3
@@ -13,14 +13,22 @@
|
|||||||
# included in all copies or substantial portions of the Software.
|
# included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
|
||||||
import mediafile
|
import mediafile
|
||||||
|
|
||||||
import warnings
|
warnings.warn(
|
||||||
warnings.warn("beets.mediafile is deprecated; use mediafile instead")
|
"beets.mediafile is deprecated; use mediafile instead",
|
||||||
|
# Show the location of the `import mediafile` statement as the warning's
|
||||||
|
# source, rather than this file, such that the offending module can be
|
||||||
|
# identified easily.
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
# Import everything from the mediafile module into this module.
|
# Import everything from the mediafile module into this module.
|
||||||
for key, value in mediafile.__dict__.items():
|
for key, value in mediafile.__dict__.items():
|
||||||
if key not in ['__name__']:
|
if key not in ["__name__"]:
|
||||||
globals()[key] = value
|
globals()[key] = value
|
||||||
|
|
||||||
|
# Cleanup namespace.
|
||||||
del key, value, warnings, mediafile
|
del key, value, warnings, mediafile
|
||||||
|
|||||||
+124
-84
@@ -15,26 +15,25 @@
|
|||||||
"""Support for beets plugins."""
|
"""Support for beets plugins."""
|
||||||
|
|
||||||
|
|
||||||
import traceback
|
|
||||||
import re
|
|
||||||
import inspect
|
|
||||||
import abc
|
import abc
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
|
import traceback
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
|
import mediafile
|
||||||
|
|
||||||
import beets
|
import beets
|
||||||
from beets import logging
|
from beets import logging
|
||||||
import mediafile
|
|
||||||
|
|
||||||
|
PLUGIN_NAMESPACE = "beetsplug"
|
||||||
PLUGIN_NAMESPACE = 'beetsplug'
|
|
||||||
|
|
||||||
# Plugins using the Last.fm API can share the same API key.
|
# Plugins using the Last.fm API can share the same API key.
|
||||||
LASTFM_KEY = '2dc3914abf35f0d9c92d97d8f8e42b43'
|
LASTFM_KEY = "2dc3914abf35f0d9c92d97d8f8e42b43"
|
||||||
|
|
||||||
# Global logger.
|
# Global logger.
|
||||||
log = logging.getLogger('beets')
|
log = logging.getLogger("beets")
|
||||||
|
|
||||||
|
|
||||||
class PluginConflictException(Exception):
|
class PluginConflictException(Exception):
|
||||||
@@ -51,11 +50,10 @@ class PluginLogFilter(logging.Filter):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, plugin):
|
def __init__(self, plugin):
|
||||||
self.prefix = f'{plugin.name}: '
|
self.prefix = f"{plugin.name}: "
|
||||||
|
|
||||||
def filter(self, record):
|
def filter(self, record):
|
||||||
if hasattr(record.msg, 'msg') and isinstance(record.msg.msg,
|
if hasattr(record.msg, "msg") and isinstance(record.msg.msg, str):
|
||||||
str):
|
|
||||||
# A _LogMessage from our hacked-up Logging replacement.
|
# A _LogMessage from our hacked-up Logging replacement.
|
||||||
record.msg.msg = self.prefix + record.msg.msg
|
record.msg.msg = self.prefix + record.msg.msg
|
||||||
elif isinstance(record.msg, str):
|
elif isinstance(record.msg, str):
|
||||||
@@ -65,6 +63,7 @@ class PluginLogFilter(logging.Filter):
|
|||||||
|
|
||||||
# Managing the plugins themselves.
|
# Managing the plugins themselves.
|
||||||
|
|
||||||
|
|
||||||
class BeetsPlugin:
|
class BeetsPlugin:
|
||||||
"""The base class for all beets plugins. Plugins provide
|
"""The base class for all beets plugins. Plugins provide
|
||||||
functionality by defining a subclass of BeetsPlugin and overriding
|
functionality by defining a subclass of BeetsPlugin and overriding
|
||||||
@@ -72,9 +71,8 @@ class BeetsPlugin:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, name=None):
|
def __init__(self, name=None):
|
||||||
"""Perform one-time plugin setup.
|
"""Perform one-time plugin setup."""
|
||||||
"""
|
self.name = name or self.__module__.split(".")[-1]
|
||||||
self.name = name or self.__module__.split('.')[-1]
|
|
||||||
self.config = beets.config[self.name]
|
self.config = beets.config[self.name]
|
||||||
if not self.template_funcs:
|
if not self.template_funcs:
|
||||||
self.template_funcs = {}
|
self.template_funcs = {}
|
||||||
@@ -97,10 +95,11 @@ class BeetsPlugin:
|
|||||||
return ()
|
return ()
|
||||||
|
|
||||||
def _set_stage_log_level(self, stages):
|
def _set_stage_log_level(self, stages):
|
||||||
"""Adjust all the stages in `stages` to WARNING logging level.
|
"""Adjust all the stages in `stages` to WARNING logging level."""
|
||||||
"""
|
return [
|
||||||
return [self._set_log_level_and_params(logging.WARNING, stage)
|
self._set_log_level_and_params(logging.WARNING, stage)
|
||||||
for stage in stages]
|
for stage in stages
|
||||||
|
]
|
||||||
|
|
||||||
def get_early_import_stages(self):
|
def get_early_import_stages(self):
|
||||||
"""Return a list of functions that should be called as importer
|
"""Return a list of functions that should be called as importer
|
||||||
@@ -134,12 +133,11 @@ class BeetsPlugin:
|
|||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
assert self._log.level == logging.NOTSET
|
assert self._log.level == logging.NOTSET
|
||||||
|
|
||||||
verbosity = beets.config['verbose'].get(int)
|
verbosity = beets.config["verbose"].get(int)
|
||||||
log_level = max(logging.DEBUG, base_log_level - 10 * verbosity)
|
log_level = max(logging.DEBUG, base_log_level - 10 * verbosity)
|
||||||
self._log.setLevel(log_level)
|
self._log.setLevel(log_level)
|
||||||
if argspec.varkw is None:
|
if argspec.varkw is None:
|
||||||
kwargs = {k: v for k, v in kwargs.items()
|
kwargs = {k: v for k, v in kwargs.items() if k in argspec.args}
|
||||||
if k in argspec.args}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
@@ -149,8 +147,7 @@ class BeetsPlugin:
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
def queries(self):
|
def queries(self):
|
||||||
"""Should return a dict mapping prefixes to Query subclasses.
|
"""Return a dict mapping prefixes to Query subclasses."""
|
||||||
"""
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def track_distance(self, item, info):
|
def track_distance(self, item, info):
|
||||||
@@ -201,6 +198,7 @@ class BeetsPlugin:
|
|||||||
"""
|
"""
|
||||||
# Defer import to prevent circular dependency
|
# Defer import to prevent circular dependency
|
||||||
from beets import library
|
from beets import library
|
||||||
|
|
||||||
mediafile.MediaFile.add_field(name, descriptor)
|
mediafile.MediaFile.add_field(name, descriptor)
|
||||||
library.Item._media_fields.add(name)
|
library.Item._media_fields.add(name)
|
||||||
|
|
||||||
@@ -208,8 +206,7 @@ class BeetsPlugin:
|
|||||||
listeners = None
|
listeners = None
|
||||||
|
|
||||||
def register_listener(self, event, func):
|
def register_listener(self, event, func):
|
||||||
"""Add a function as a listener for the specified event.
|
"""Add a function as a listener for the specified event."""
|
||||||
"""
|
|
||||||
wrapped_func = self._set_log_level_and_params(logging.WARNING, func)
|
wrapped_func = self._set_log_level_and_params(logging.WARNING, func)
|
||||||
|
|
||||||
cls = self.__class__
|
cls = self.__class__
|
||||||
@@ -230,11 +227,13 @@ class BeetsPlugin:
|
|||||||
function will be invoked as ``%name{}`` from path format
|
function will be invoked as ``%name{}`` from path format
|
||||||
strings.
|
strings.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def helper(func):
|
def helper(func):
|
||||||
if cls.template_funcs is None:
|
if cls.template_funcs is None:
|
||||||
cls.template_funcs = {}
|
cls.template_funcs = {}
|
||||||
cls.template_funcs[name] = func
|
cls.template_funcs[name] = func
|
||||||
return func
|
return func
|
||||||
|
|
||||||
return helper
|
return helper
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -244,11 +243,13 @@ class BeetsPlugin:
|
|||||||
strings. The function must accept a single parameter, the Item
|
strings. The function must accept a single parameter, the Item
|
||||||
being formatted.
|
being formatted.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def helper(func):
|
def helper(func):
|
||||||
if cls.template_fields is None:
|
if cls.template_fields is None:
|
||||||
cls.template_fields = {}
|
cls.template_fields = {}
|
||||||
cls.template_fields[name] = func
|
cls.template_fields[name] = func
|
||||||
return func
|
return func
|
||||||
|
|
||||||
return helper
|
return helper
|
||||||
|
|
||||||
|
|
||||||
@@ -262,25 +263,29 @@ def load_plugins(names=()):
|
|||||||
BeetsPlugin subclasses desired.
|
BeetsPlugin subclasses desired.
|
||||||
"""
|
"""
|
||||||
for name in names:
|
for name in names:
|
||||||
modname = f'{PLUGIN_NAMESPACE}.{name}'
|
modname = f"{PLUGIN_NAMESPACE}.{name}"
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
namespace = __import__(modname, None, None)
|
namespace = __import__(modname, None, None)
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
# Again, this is hacky:
|
# Again, this is hacky:
|
||||||
if exc.args[0].endswith(' ' + name):
|
if exc.args[0].endswith(" " + name):
|
||||||
log.warning('** plugin {0} not found', name)
|
log.warning("** plugin {0} not found", name)
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
for obj in getattr(namespace, name).__dict__.values():
|
for obj in getattr(namespace, name).__dict__.values():
|
||||||
if isinstance(obj, type) and issubclass(obj, BeetsPlugin) \
|
if (
|
||||||
and obj != BeetsPlugin and obj not in _classes:
|
isinstance(obj, type)
|
||||||
|
and issubclass(obj, BeetsPlugin)
|
||||||
|
and obj != BeetsPlugin
|
||||||
|
and obj not in _classes
|
||||||
|
):
|
||||||
_classes.add(obj)
|
_classes.add(obj)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
log.warning(
|
log.warning(
|
||||||
'** error loading plugin {}:\n{}',
|
"** error loading plugin {}:\n{}",
|
||||||
name,
|
name,
|
||||||
traceback.format_exc(),
|
traceback.format_exc(),
|
||||||
)
|
)
|
||||||
@@ -311,9 +316,9 @@ def find_plugins():
|
|||||||
|
|
||||||
# Communication with plugins.
|
# Communication with plugins.
|
||||||
|
|
||||||
|
|
||||||
def commands():
|
def commands():
|
||||||
"""Returns a list of Subcommand objects from all loaded plugins.
|
"""Returns a list of Subcommand objects from all loaded plugins."""
|
||||||
"""
|
|
||||||
out = []
|
out = []
|
||||||
for plugin in find_plugins():
|
for plugin in find_plugins():
|
||||||
out += plugin.commands()
|
out += plugin.commands()
|
||||||
@@ -332,16 +337,16 @@ def queries():
|
|||||||
|
|
||||||
def types(model_cls):
|
def types(model_cls):
|
||||||
# Gives us `item_types` and `album_types`
|
# Gives us `item_types` and `album_types`
|
||||||
attr_name = f'{model_cls.__name__.lower()}_types'
|
attr_name = f"{model_cls.__name__.lower()}_types"
|
||||||
types = {}
|
types = {}
|
||||||
for plugin in find_plugins():
|
for plugin in find_plugins():
|
||||||
plugin_types = getattr(plugin, attr_name, {})
|
plugin_types = getattr(plugin, attr_name, {})
|
||||||
for field in plugin_types:
|
for field in plugin_types:
|
||||||
if field in types and plugin_types[field] != types[field]:
|
if field in types and plugin_types[field] != types[field]:
|
||||||
raise PluginConflictException(
|
raise PluginConflictException(
|
||||||
'Plugin {} defines flexible field {} '
|
"Plugin {} defines flexible field {} "
|
||||||
'which has already been defined with '
|
"which has already been defined with "
|
||||||
'another type.'.format(plugin.name, field)
|
"another type.".format(plugin.name, field)
|
||||||
)
|
)
|
||||||
types.update(plugin_types)
|
types.update(plugin_types)
|
||||||
return types
|
return types
|
||||||
@@ -349,7 +354,7 @@ def types(model_cls):
|
|||||||
|
|
||||||
def named_queries(model_cls):
|
def named_queries(model_cls):
|
||||||
# Gather `item_queries` and `album_queries` from the plugins.
|
# Gather `item_queries` and `album_queries` from the plugins.
|
||||||
attr_name = f'{model_cls.__name__.lower()}_queries'
|
attr_name = f"{model_cls.__name__.lower()}_queries"
|
||||||
queries = {}
|
queries = {}
|
||||||
for plugin in find_plugins():
|
for plugin in find_plugins():
|
||||||
plugin_queries = getattr(plugin, attr_name, {})
|
plugin_queries = getattr(plugin, attr_name, {})
|
||||||
@@ -362,6 +367,7 @@ def track_distance(item, info):
|
|||||||
Returns a Distance object.
|
Returns a Distance object.
|
||||||
"""
|
"""
|
||||||
from beets.autotag.hooks import Distance
|
from beets.autotag.hooks import Distance
|
||||||
|
|
||||||
dist = Distance()
|
dist = Distance()
|
||||||
for plugin in find_plugins():
|
for plugin in find_plugins():
|
||||||
dist.update(plugin.track_distance(item, info))
|
dist.update(plugin.track_distance(item, info))
|
||||||
@@ -371,6 +377,7 @@ def track_distance(item, info):
|
|||||||
def album_distance(items, album_info, mapping):
|
def album_distance(items, album_info, mapping):
|
||||||
"""Returns the album distance calculated by plugins."""
|
"""Returns the album distance calculated by plugins."""
|
||||||
from beets.autotag.hooks import Distance
|
from beets.autotag.hooks import Distance
|
||||||
|
|
||||||
dist = Distance()
|
dist = Distance()
|
||||||
for plugin in find_plugins():
|
for plugin in find_plugins():
|
||||||
dist.update(plugin.album_distance(items, album_info, mapping))
|
dist.update(plugin.album_distance(items, album_info, mapping))
|
||||||
@@ -378,23 +385,21 @@ def album_distance(items, album_info, mapping):
|
|||||||
|
|
||||||
|
|
||||||
def candidates(items, artist, album, va_likely, extra_tags=None):
|
def candidates(items, artist, album, va_likely, extra_tags=None):
|
||||||
"""Gets MusicBrainz candidates for an album from each plugin.
|
"""Gets MusicBrainz candidates for an album from each plugin."""
|
||||||
"""
|
|
||||||
for plugin in find_plugins():
|
for plugin in find_plugins():
|
||||||
yield from plugin.candidates(items, artist, album, va_likely,
|
yield from plugin.candidates(
|
||||||
extra_tags)
|
items, artist, album, va_likely, extra_tags
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def item_candidates(item, artist, title):
|
def item_candidates(item, artist, title):
|
||||||
"""Gets MusicBrainz candidates for an item from the plugins.
|
"""Gets MusicBrainz candidates for an item from the plugins."""
|
||||||
"""
|
|
||||||
for plugin in find_plugins():
|
for plugin in find_plugins():
|
||||||
yield from plugin.item_candidates(item, artist, title)
|
yield from plugin.item_candidates(item, artist, title)
|
||||||
|
|
||||||
|
|
||||||
def album_for_id(album_id):
|
def album_for_id(album_id):
|
||||||
"""Get AlbumInfo objects for a given ID string.
|
"""Get AlbumInfo objects for a given ID string."""
|
||||||
"""
|
|
||||||
for plugin in find_plugins():
|
for plugin in find_plugins():
|
||||||
album = plugin.album_for_id(album_id)
|
album = plugin.album_for_id(album_id)
|
||||||
if album:
|
if album:
|
||||||
@@ -402,8 +407,7 @@ def album_for_id(album_id):
|
|||||||
|
|
||||||
|
|
||||||
def track_for_id(track_id):
|
def track_for_id(track_id):
|
||||||
"""Get TrackInfo objects for a given ID string.
|
"""Get TrackInfo objects for a given ID string."""
|
||||||
"""
|
|
||||||
for plugin in find_plugins():
|
for plugin in find_plugins():
|
||||||
track = plugin.track_for_id(track_id)
|
track = plugin.track_for_id(track_id)
|
||||||
if track:
|
if track:
|
||||||
@@ -439,29 +443,44 @@ def import_stages():
|
|||||||
|
|
||||||
# New-style (lazy) plugin-provided fields.
|
# New-style (lazy) plugin-provided fields.
|
||||||
|
|
||||||
|
|
||||||
|
def _check_conflicts_and_merge(plugin, plugin_funcs, funcs):
|
||||||
|
"""Check the provided template functions for conflicts and merge into funcs.
|
||||||
|
|
||||||
|
Raises a `PluginConflictException` if a plugin defines template functions
|
||||||
|
for fields that another plugin has already defined template functions for.
|
||||||
|
"""
|
||||||
|
if plugin_funcs:
|
||||||
|
if not plugin_funcs.keys().isdisjoint(funcs.keys()):
|
||||||
|
conflicted_fields = ", ".join(plugin_funcs.keys() & funcs.keys())
|
||||||
|
raise PluginConflictException(
|
||||||
|
f"Plugin {plugin.name} defines template functions for "
|
||||||
|
f"{conflicted_fields} that conflict with another plugin."
|
||||||
|
)
|
||||||
|
funcs.update(plugin_funcs)
|
||||||
|
|
||||||
|
|
||||||
def item_field_getters():
|
def item_field_getters():
|
||||||
"""Get a dictionary mapping field names to unary functions that
|
"""Get a dictionary mapping field names to unary functions that
|
||||||
compute the field's value.
|
compute the field's value.
|
||||||
"""
|
"""
|
||||||
funcs = {}
|
funcs = {}
|
||||||
for plugin in find_plugins():
|
for plugin in find_plugins():
|
||||||
if plugin.template_fields:
|
_check_conflicts_and_merge(plugin, plugin.template_fields, funcs)
|
||||||
funcs.update(plugin.template_fields)
|
|
||||||
return funcs
|
return funcs
|
||||||
|
|
||||||
|
|
||||||
def album_field_getters():
|
def album_field_getters():
|
||||||
"""As above, for album fields.
|
"""As above, for album fields."""
|
||||||
"""
|
|
||||||
funcs = {}
|
funcs = {}
|
||||||
for plugin in find_plugins():
|
for plugin in find_plugins():
|
||||||
if plugin.album_template_fields:
|
_check_conflicts_and_merge(plugin, plugin.album_template_fields, funcs)
|
||||||
funcs.update(plugin.album_template_fields)
|
|
||||||
return funcs
|
return funcs
|
||||||
|
|
||||||
|
|
||||||
# Event dispatch.
|
# Event dispatch.
|
||||||
|
|
||||||
|
|
||||||
def event_handlers():
|
def event_handlers():
|
||||||
"""Find all event handlers from plugins as a dictionary mapping
|
"""Find all event handlers from plugins as a dictionary mapping
|
||||||
event names to sequences of callables.
|
event names to sequences of callables.
|
||||||
@@ -482,7 +501,7 @@ def send(event, **arguments):
|
|||||||
|
|
||||||
Return a list of non-None values returned from the handlers.
|
Return a list of non-None values returned from the handlers.
|
||||||
"""
|
"""
|
||||||
log.debug('Sending event: {0}', event)
|
log.debug("Sending event: {0}", event)
|
||||||
results = []
|
results = []
|
||||||
for handler in event_handlers()[event]:
|
for handler in event_handlers()[event]:
|
||||||
result = handler(**arguments)
|
result = handler(**arguments)
|
||||||
@@ -497,11 +516,11 @@ def feat_tokens(for_artist=True):
|
|||||||
The `for_artist` option determines whether the regex should be
|
The `for_artist` option determines whether the regex should be
|
||||||
suitable for matching artist fields (the default) or title fields.
|
suitable for matching artist fields (the default) or title fields.
|
||||||
"""
|
"""
|
||||||
feat_words = ['ft', 'featuring', 'feat', 'feat.', 'ft.']
|
feat_words = ["ft", "featuring", "feat", "feat.", "ft."]
|
||||||
if for_artist:
|
if for_artist:
|
||||||
feat_words += ['with', 'vs', 'and', 'con', '&']
|
feat_words += ["with", "vs", "and", "con", "&"]
|
||||||
return r'(?<=\s)(?:{})(?=\s)'.format(
|
return r"(?<=\s)(?:{})(?=\s)".format(
|
||||||
'|'.join(re.escape(x) for x in feat_words)
|
"|".join(re.escape(x) for x in feat_words)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -517,7 +536,7 @@ def sanitize_choices(choices, choices_all):
|
|||||||
if s not in seen:
|
if s not in seen:
|
||||||
if s in list(choices_all):
|
if s in list(choices_all):
|
||||||
res.append(s)
|
res.append(s)
|
||||||
elif s == '*':
|
elif s == "*":
|
||||||
res.extend(others)
|
res.extend(others)
|
||||||
seen.add(s)
|
seen.add(s)
|
||||||
return res
|
return res
|
||||||
@@ -550,11 +569,11 @@ def sanitize_pairs(pairs, pairs_all):
|
|||||||
if x not in seen:
|
if x not in seen:
|
||||||
seen.add(x)
|
seen.add(x)
|
||||||
res.append(x)
|
res.append(x)
|
||||||
elif k == '*':
|
elif k == "*":
|
||||||
new = [o for o in others if o not in seen]
|
new = [o for o in others if o not in seen]
|
||||||
seen.update(new)
|
seen.update(new)
|
||||||
res.extend(new)
|
res.extend(new)
|
||||||
elif v == '*':
|
elif v == "*":
|
||||||
new = [o for o in others if o not in seen and o[0] == k]
|
new = [o for o in others if o not in seen and o[0] == k]
|
||||||
seen.update(new)
|
seen.update(new)
|
||||||
res.extend(new)
|
res.extend(new)
|
||||||
@@ -568,12 +587,15 @@ def notify_info_yielded(event):
|
|||||||
Each yielded value is passed to plugins using the 'info' parameter of
|
Each yielded value is passed to plugins using the 'info' parameter of
|
||||||
'send'.
|
'send'.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(generator):
|
def decorator(generator):
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
for v in generator(*args, **kwargs):
|
for v in generator(*args, **kwargs):
|
||||||
send(event, info=v)
|
send(event, info=v)
|
||||||
yield v
|
yield v
|
||||||
|
|
||||||
return decorated
|
return decorated
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
@@ -583,7 +605,7 @@ def get_distance(config, data_source, info):
|
|||||||
"""
|
"""
|
||||||
dist = beets.autotag.Distance()
|
dist = beets.autotag.Distance()
|
||||||
if info.data_source == data_source:
|
if info.data_source == data_source:
|
||||||
dist.add('source', config['source_weight'].as_number())
|
dist.add("source", config["source_weight"].as_number())
|
||||||
return dist
|
return dist
|
||||||
|
|
||||||
|
|
||||||
@@ -620,7 +642,7 @@ def apply_item_changes(lib, item, move, pretend, write):
|
|||||||
class MetadataSourcePlugin(metaclass=abc.ABCMeta):
|
class MetadataSourcePlugin(metaclass=abc.ABCMeta):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.config.add({'source_weight': 0.5})
|
self.config.add({"source_weight": 0.5})
|
||||||
|
|
||||||
@abc.abstractproperty
|
@abc.abstractproperty
|
||||||
def id_regex(self):
|
def id_regex(self):
|
||||||
@@ -643,7 +665,7 @@ class MetadataSourcePlugin(metaclass=abc.ABCMeta):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def _search_api(self, query_type, filters, keywords=''):
|
def _search_api(self, query_type, filters, keywords=""):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
@@ -655,7 +677,7 @@ class MetadataSourcePlugin(metaclass=abc.ABCMeta):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_artist(artists, id_key='id', name_key='name'):
|
def get_artist(artists, id_key="id", name_key="name", join_key=None):
|
||||||
"""Returns an artist string (all artists) and an artist_id (the main
|
"""Returns an artist string (all artists) and an artist_id (the main
|
||||||
artist) for a list of artist object dicts.
|
artist) for a list of artist object dicts.
|
||||||
|
|
||||||
@@ -663,6 +685,8 @@ class MetadataSourcePlugin(metaclass=abc.ABCMeta):
|
|||||||
and 'the') to the front and strips trailing disambiguation numbers. It
|
and 'the') to the front and strips trailing disambiguation numbers. It
|
||||||
returns a tuple containing the comma-separated string of all
|
returns a tuple containing the comma-separated string of all
|
||||||
normalized artists and the ``id`` of the main/first artist.
|
normalized artists and the ``id`` of the main/first artist.
|
||||||
|
Alternatively a keyword can be used to combine artists together into a
|
||||||
|
single string by passing the join_key argument.
|
||||||
|
|
||||||
:param artists: Iterable of artist dicts or lists returned by API.
|
:param artists: Iterable of artist dicts or lists returned by API.
|
||||||
:type artists: list[dict] or list[list]
|
:type artists: list[dict] or list[list]
|
||||||
@@ -673,39 +697,55 @@ class MetadataSourcePlugin(metaclass=abc.ABCMeta):
|
|||||||
to concatenate for the artist string (containing all artists).
|
to concatenate for the artist string (containing all artists).
|
||||||
Defaults to 'name'.
|
Defaults to 'name'.
|
||||||
:type name_key: str or int
|
:type name_key: str or int
|
||||||
|
:param join_key: Key or index corresponding to a field containing a
|
||||||
|
keyword to use for combining artists into a single string, for
|
||||||
|
example "Feat.", "Vs.", "And" or similar. The default is None
|
||||||
|
which keeps the default behaviour (comma-separated).
|
||||||
|
:type join_key: str or int
|
||||||
:return: Normalized artist string.
|
:return: Normalized artist string.
|
||||||
:rtype: str
|
:rtype: str
|
||||||
"""
|
"""
|
||||||
artist_id = None
|
artist_id = None
|
||||||
artist_names = []
|
artist_string = ""
|
||||||
for artist in artists:
|
artists = list(artists) # In case a generator was passed.
|
||||||
|
total = len(artists)
|
||||||
|
for idx, artist in enumerate(artists):
|
||||||
if not artist_id:
|
if not artist_id:
|
||||||
artist_id = artist[id_key]
|
artist_id = artist[id_key]
|
||||||
name = artist[name_key]
|
name = artist[name_key]
|
||||||
# Strip disambiguation number.
|
# Strip disambiguation number.
|
||||||
name = re.sub(r' \(\d+\)$', '', name)
|
name = re.sub(r" \(\d+\)$", "", name)
|
||||||
# Move articles to the front.
|
# Move articles to the front.
|
||||||
name = re.sub(r'^(.*?), (a|an|the)$', r'\2 \1', name, flags=re.I)
|
name = re.sub(r"^(.*?), (a|an|the)$", r"\2 \1", name, flags=re.I)
|
||||||
artist_names.append(name)
|
# Use a join keyword if requested and available.
|
||||||
artist = ', '.join(artist_names).replace(' ,', ',') or None
|
if idx < (total - 1): # Skip joining on last.
|
||||||
return artist, artist_id
|
if join_key and artist.get(join_key, None):
|
||||||
|
name += f" {artist[join_key]} "
|
||||||
|
else:
|
||||||
|
name += ", "
|
||||||
|
artist_string += name
|
||||||
|
|
||||||
def _get_id(self, url_type, id_):
|
return artist_string, artist_id
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_id(url_type, id_, id_regex):
|
||||||
"""Parse an ID from its URL if necessary.
|
"""Parse an ID from its URL if necessary.
|
||||||
|
|
||||||
:param url_type: Type of URL. Either 'album' or 'track'.
|
:param url_type: Type of URL. Either 'album' or 'track'.
|
||||||
:type url_type: str
|
:type url_type: str
|
||||||
:param id_: Album/track ID or URL.
|
:param id_: Album/track ID or URL.
|
||||||
:type id_: str
|
:type id_: str
|
||||||
|
:param id_regex: A dictionary containing a regular expression
|
||||||
|
extracting an ID from an URL (if it's not an ID already) in
|
||||||
|
'pattern' and the number of the match group in 'match_group'.
|
||||||
|
:type id_regex: dict
|
||||||
:return: Album/track ID.
|
:return: Album/track ID.
|
||||||
:rtype: str
|
:rtype: str
|
||||||
"""
|
"""
|
||||||
self._log.debug(
|
log.debug("Extracting {} ID from '{}'", url_type, id_)
|
||||||
"Searching {} for {} '{}'", self.data_source, url_type, id_
|
match = re.search(id_regex["pattern"].format(url_type), str(id_))
|
||||||
)
|
|
||||||
match = re.search(self.id_regex['pattern'].format(url_type), str(id_))
|
|
||||||
if match:
|
if match:
|
||||||
id_ = match.group(self.id_regex['match_group'])
|
id_ = match.group(id_regex["match_group"])
|
||||||
if id_:
|
if id_:
|
||||||
return id_
|
return id_
|
||||||
return None
|
return None
|
||||||
@@ -726,11 +766,11 @@ class MetadataSourcePlugin(metaclass=abc.ABCMeta):
|
|||||||
:return: Candidate AlbumInfo objects.
|
:return: Candidate AlbumInfo objects.
|
||||||
:rtype: list[beets.autotag.hooks.AlbumInfo]
|
:rtype: list[beets.autotag.hooks.AlbumInfo]
|
||||||
"""
|
"""
|
||||||
query_filters = {'album': album}
|
query_filters = {"album": album}
|
||||||
if not va_likely:
|
if not va_likely:
|
||||||
query_filters['artist'] = artist
|
query_filters["artist"] = artist
|
||||||
results = self._search_api(query_type='album', filters=query_filters)
|
results = self._search_api(query_type="album", filters=query_filters)
|
||||||
albums = [self.album_for_id(album_id=r['id']) for r in results]
|
albums = [self.album_for_id(album_id=r["id"]) for r in results]
|
||||||
return [a for a in albums if a is not None]
|
return [a for a in albums if a is not None]
|
||||||
|
|
||||||
def item_candidates(self, item, artist, title):
|
def item_candidates(self, item, artist, title):
|
||||||
@@ -747,7 +787,7 @@ class MetadataSourcePlugin(metaclass=abc.ABCMeta):
|
|||||||
:rtype: list[beets.autotag.hooks.TrackInfo]
|
:rtype: list[beets.autotag.hooks.TrackInfo]
|
||||||
"""
|
"""
|
||||||
tracks = self._search_api(
|
tracks = self._search_api(
|
||||||
query_type='track', keywords=title, filters={'artist': artist}
|
query_type="track", keywords=title, filters={"artist": artist}
|
||||||
)
|
)
|
||||||
return [self.track_for_id(track_data=track) for track in tracks]
|
return [self.track_for_id(track_data=track) for track in tracks]
|
||||||
|
|
||||||
|
|||||||
+7
-8
@@ -12,24 +12,22 @@
|
|||||||
# The above copyright notice and this permission notice shall be
|
# The above copyright notice and this permission notice shall be
|
||||||
# included in all copies or substantial portions of the Software.
|
# included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
"""Get a random song or album from the library.
|
"""Get a random song or album from the library."""
|
||||||
"""
|
|
||||||
|
|
||||||
import random
|
import random
|
||||||
from operator import attrgetter
|
|
||||||
from itertools import groupby
|
from itertools import groupby
|
||||||
|
from operator import attrgetter
|
||||||
|
|
||||||
|
|
||||||
def _length(obj, album):
|
def _length(obj, album):
|
||||||
"""Get the duration of an item or album.
|
"""Get the duration of an item or album."""
|
||||||
"""
|
|
||||||
if album:
|
if album:
|
||||||
return sum(i.length for i in obj.items())
|
return sum(i.length for i in obj.items())
|
||||||
else:
|
else:
|
||||||
return obj.length
|
return obj.length
|
||||||
|
|
||||||
|
|
||||||
def _equal_chance_permutation(objs, field='albumartist', random_gen=None):
|
def _equal_chance_permutation(objs, field="albumartist", random_gen=None):
|
||||||
"""Generate (lazily) a permutation of the objects where every group
|
"""Generate (lazily) a permutation of the objects where every group
|
||||||
with equal values for `field` have an equal chance of appearing in
|
with equal values for `field` have an equal chance of appearing in
|
||||||
any given position.
|
any given position.
|
||||||
@@ -86,8 +84,9 @@ def _take_time(iter, secs, album):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def random_objs(objs, album, number=1, time=None, equal_chance=False,
|
def random_objs(
|
||||||
random_gen=None):
|
objs, album, number=1, time=None, equal_chance=False, random_gen=None
|
||||||
|
):
|
||||||
"""Get a random subset of the provided `objs`.
|
"""Get a random subset of the provided `objs`.
|
||||||
|
|
||||||
If `number` is provided, produce that many matches. Otherwise, if
|
If `number` is provided, produce that many matches. Otherwise, if
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# This file is part of beets.
|
# This file is part of beets.
|
||||||
# Copyright 2016-2019, Adrian Sampson.
|
# Copyright 2024, Lars Kruse
|
||||||
#
|
#
|
||||||
# Permission is hereby granted, free of charge, to any person obtaining
|
# Permission is hereby granted, free of charge, to any person obtaining
|
||||||
# a copy of this software and associated documentation files (the
|
# a copy of this software and associated documentation files (the
|
||||||
@@ -12,17 +12,8 @@
|
|||||||
# The above copyright notice and this permission notice shall be
|
# The above copyright notice and this permission notice shall be
|
||||||
# included in all copies or substantial portions of the Software.
|
# included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
"""This module contains components of beets' test environment, which
|
||||||
import confuse
|
may be of use for testing procedures of external libraries or programs.
|
||||||
|
For example the 'TestHelper' class may be useful for creating an
|
||||||
import warnings
|
in-memory beets library filled with a few example items.
|
||||||
warnings.warn("beets.util.confit is deprecated; use confuse instead")
|
"""
|
||||||
|
|
||||||
# Import everything from the confuse module into this module.
|
|
||||||
for key, value in confuse.__dict__.items():
|
|
||||||
if key not in ['__name__']:
|
|
||||||
globals()[key] = value
|
|
||||||
|
|
||||||
|
|
||||||
# Cleanup namespace.
|
|
||||||
del key, value, warnings, confuse
|
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
# This file is part of beets.
|
||||||
|
# Copyright 2016, Adrian Sampson.
|
||||||
|
#
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
# a copy of this software and associated documentation files (the
|
||||||
|
# "Software"), to deal in the Software without restriction, including
|
||||||
|
# without limitation the rights to use, copy, modify, merge, publish,
|
||||||
|
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||||
|
# permit persons to whom the Software is furnished to do so, subject to
|
||||||
|
# the following conditions:
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be
|
||||||
|
# included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
"""Some common functionality for beets' test cases."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
import beets
|
||||||
|
import beets.library
|
||||||
|
|
||||||
|
# Make sure the development versions of the plugins are used
|
||||||
|
import beetsplug
|
||||||
|
from beets import importer, logging, util
|
||||||
|
from beets.ui import commands
|
||||||
|
from beets.util import syspath
|
||||||
|
|
||||||
|
beetsplug.__path__ = [
|
||||||
|
os.path.abspath(
|
||||||
|
os.path.join(
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
os.path.pardir,
|
||||||
|
os.path.pardir,
|
||||||
|
"beetsplug",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Test resources path.
|
||||||
|
RSRC = util.bytestring_path(
|
||||||
|
os.path.abspath(
|
||||||
|
os.path.join(
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
os.path.pardir,
|
||||||
|
os.path.pardir,
|
||||||
|
"test",
|
||||||
|
"rsrc",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
PLUGINPATH = os.path.join(RSRC.decode(), "beetsplug")
|
||||||
|
|
||||||
|
# Propagate to root logger so the test runner can capture it
|
||||||
|
log = logging.getLogger("beets")
|
||||||
|
log.propagate = True
|
||||||
|
log.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
# Dummy item creation.
|
||||||
|
_item_ident = 0
|
||||||
|
|
||||||
|
# OS feature test.
|
||||||
|
HAVE_SYMLINK = sys.platform != "win32"
|
||||||
|
HAVE_HARDLINK = sys.platform != "win32"
|
||||||
|
|
||||||
|
try:
|
||||||
|
import reflink
|
||||||
|
|
||||||
|
HAVE_REFLINK = reflink.supported_at(tempfile.gettempdir())
|
||||||
|
except ImportError:
|
||||||
|
HAVE_REFLINK = False
|
||||||
|
|
||||||
|
|
||||||
|
def item(lib=None):
|
||||||
|
global _item_ident
|
||||||
|
_item_ident += 1
|
||||||
|
i = beets.library.Item(
|
||||||
|
title="the title",
|
||||||
|
artist="the artist",
|
||||||
|
albumartist="the album artist",
|
||||||
|
album="the album",
|
||||||
|
genre="the genre",
|
||||||
|
lyricist="the lyricist",
|
||||||
|
composer="the composer",
|
||||||
|
arranger="the arranger",
|
||||||
|
grouping="the grouping",
|
||||||
|
work="the work title",
|
||||||
|
mb_workid="the work musicbrainz id",
|
||||||
|
work_disambig="the work disambiguation",
|
||||||
|
year=1,
|
||||||
|
month=2,
|
||||||
|
day=3,
|
||||||
|
track=4,
|
||||||
|
tracktotal=5,
|
||||||
|
disc=6,
|
||||||
|
disctotal=7,
|
||||||
|
lyrics="the lyrics",
|
||||||
|
comments="the comments",
|
||||||
|
bpm=8,
|
||||||
|
comp=True,
|
||||||
|
path=f"somepath{_item_ident}",
|
||||||
|
length=60.0,
|
||||||
|
bitrate=128000,
|
||||||
|
format="FLAC",
|
||||||
|
mb_trackid="someID-1",
|
||||||
|
mb_albumid="someID-2",
|
||||||
|
mb_artistid="someID-3",
|
||||||
|
mb_albumartistid="someID-4",
|
||||||
|
mb_releasetrackid="someID-5",
|
||||||
|
album_id=None,
|
||||||
|
mtime=12345,
|
||||||
|
)
|
||||||
|
if lib:
|
||||||
|
lib.add(i)
|
||||||
|
return i
|
||||||
|
|
||||||
|
|
||||||
|
def album(lib=None):
|
||||||
|
global _item_ident
|
||||||
|
_item_ident += 1
|
||||||
|
i = beets.library.Album(
|
||||||
|
artpath=None,
|
||||||
|
albumartist="some album artist",
|
||||||
|
albumartist_sort="some sort album artist",
|
||||||
|
albumartist_credit="some album artist credit",
|
||||||
|
album="the album",
|
||||||
|
genre="the genre",
|
||||||
|
year=2014,
|
||||||
|
month=2,
|
||||||
|
day=5,
|
||||||
|
tracktotal=0,
|
||||||
|
disctotal=1,
|
||||||
|
comp=False,
|
||||||
|
mb_albumid="someID-1",
|
||||||
|
mb_albumartistid="someID-1",
|
||||||
|
)
|
||||||
|
if lib:
|
||||||
|
lib.add(i)
|
||||||
|
return i
|
||||||
|
|
||||||
|
|
||||||
|
# Dummy import session.
|
||||||
|
def import_session(lib=None, loghandler=None, paths=[], query=[], cli=False):
|
||||||
|
cls = commands.TerminalImportSession if cli else importer.ImportSession
|
||||||
|
return cls(lib, loghandler, paths, query)
|
||||||
|
|
||||||
|
|
||||||
|
class Assertions:
|
||||||
|
"""A mixin with additional unit test assertions."""
|
||||||
|
|
||||||
|
def assertExists(self, path): # noqa
|
||||||
|
assert os.path.exists(syspath(path)), f"file does not exist: {path!r}"
|
||||||
|
|
||||||
|
def assertNotExists(self, path): # noqa
|
||||||
|
assert not os.path.exists(syspath(path)), f"file exists: {path!r}"
|
||||||
|
|
||||||
|
def assertIsFile(self, path): # noqa
|
||||||
|
self.assertExists(path)
|
||||||
|
assert os.path.isfile(
|
||||||
|
syspath(path)
|
||||||
|
), "path exists, but is not a regular file: {!r}".format(path)
|
||||||
|
|
||||||
|
def assertIsDir(self, path): # noqa
|
||||||
|
self.assertExists(path)
|
||||||
|
assert os.path.isdir(
|
||||||
|
syspath(path)
|
||||||
|
), "path exists, but is not a directory: {!r}".format(path)
|
||||||
|
|
||||||
|
def assert_equal_path(self, a, b):
|
||||||
|
"""Check that two paths are equal."""
|
||||||
|
a_bytes, b_bytes = util.normpath(a), util.normpath(b)
|
||||||
|
|
||||||
|
assert a_bytes == b_bytes, f"{a_bytes=} != {b_bytes=}"
|
||||||
|
|
||||||
|
|
||||||
|
# Mock I/O.
|
||||||
|
|
||||||
|
|
||||||
|
class InputException(Exception):
|
||||||
|
def __init__(self, output=None):
|
||||||
|
self.output = output
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
msg = "Attempt to read with no input provided."
|
||||||
|
if self.output is not None:
|
||||||
|
msg += f" Output: {self.output!r}"
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
class DummyOut:
|
||||||
|
encoding = "utf-8"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.buf = []
|
||||||
|
|
||||||
|
def write(self, s):
|
||||||
|
self.buf.append(s)
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
return "".join(self.buf)
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
self.clear()
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
self.buf = []
|
||||||
|
|
||||||
|
|
||||||
|
class DummyIn:
|
||||||
|
encoding = "utf-8"
|
||||||
|
|
||||||
|
def __init__(self, out=None):
|
||||||
|
self.buf = []
|
||||||
|
self.reads = 0
|
||||||
|
self.out = out
|
||||||
|
|
||||||
|
def add(self, s):
|
||||||
|
self.buf.append(s + "\n")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def readline(self):
|
||||||
|
if not self.buf:
|
||||||
|
if self.out:
|
||||||
|
raise InputException(self.out.get())
|
||||||
|
else:
|
||||||
|
raise InputException()
|
||||||
|
self.reads += 1
|
||||||
|
return self.buf.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
class DummyIO:
|
||||||
|
"""Mocks input and output streams for testing UI code."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.stdout = DummyOut()
|
||||||
|
self.stdin = DummyIn(self.stdout)
|
||||||
|
|
||||||
|
def addinput(self, s):
|
||||||
|
self.stdin.add(s)
|
||||||
|
|
||||||
|
def getoutput(self):
|
||||||
|
res = self.stdout.get()
|
||||||
|
self.stdout.clear()
|
||||||
|
return res
|
||||||
|
|
||||||
|
def readcount(self):
|
||||||
|
return self.stdin.reads
|
||||||
|
|
||||||
|
def install(self):
|
||||||
|
sys.stdin = self.stdin
|
||||||
|
sys.stdout = self.stdout
|
||||||
|
|
||||||
|
def restore(self):
|
||||||
|
sys.stdin = sys.__stdin__
|
||||||
|
sys.stdout = sys.__stdout__
|
||||||
|
|
||||||
|
|
||||||
|
# Utility.
|
||||||
|
|
||||||
|
|
||||||
|
def touch(path):
|
||||||
|
open(syspath(path), "a").close()
|
||||||
|
|
||||||
|
|
||||||
|
class Bag:
|
||||||
|
"""An object that exposes a set of fields given as keyword
|
||||||
|
arguments. Any field not found in the dictionary appears to be None.
|
||||||
|
Used for mocking Album objects and the like.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, **fields):
|
||||||
|
self.fields = fields
|
||||||
|
|
||||||
|
def __getattr__(self, key):
|
||||||
|
return self.fields.get(key)
|
||||||
|
|
||||||
|
|
||||||
|
# Platform mocking.
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def platform_windows():
|
||||||
|
import ntpath
|
||||||
|
|
||||||
|
old_path = os.path
|
||||||
|
try:
|
||||||
|
os.path = ntpath
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
os.path = old_path
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def platform_posix():
|
||||||
|
import posixpath
|
||||||
|
|
||||||
|
old_path = os.path
|
||||||
|
try:
|
||||||
|
os.path = posixpath
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
os.path = old_path
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def system_mock(name):
|
||||||
|
import platform
|
||||||
|
|
||||||
|
old_system = platform.system
|
||||||
|
platform.system = lambda: name
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
platform.system = old_system
|
||||||
|
|
||||||
|
|
||||||
|
def slow_test(unused=None):
|
||||||
|
def _id(obj):
|
||||||
|
return obj
|
||||||
|
|
||||||
|
if "SKIP_SLOW_TESTS" in os.environ:
|
||||||
|
return unittest.skip("test is slow")
|
||||||
|
return _id
|
||||||
File diff suppressed because it is too large
Load Diff
+884
-304
File diff suppressed because it is too large
Load Diff
+1309
-654
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,7 @@
|
|||||||
# plugins dynamically
|
# plugins dynamically
|
||||||
#
|
#
|
||||||
# Currently, only Bash 3.2 and newer is supported and the
|
# Currently, only Bash 3.2 and newer is supported and the
|
||||||
# `bash-completion` package is requied.
|
# `bash-completion` package (v2.8 or newer) is required.
|
||||||
#
|
#
|
||||||
# TODO
|
# TODO
|
||||||
# ----
|
# ----
|
||||||
@@ -46,7 +46,30 @@
|
|||||||
# * Support long options with `=`, e.g. `--config=file`. Debian's bash
|
# * Support long options with `=`, e.g. `--config=file`. Debian's bash
|
||||||
# completion package can handle this.
|
# completion package can handle this.
|
||||||
#
|
#
|
||||||
|
# Note that 'bash-completion' v2.8 is a part of Debian 10, which is part of
|
||||||
|
# LTS until 2024-06-30. After this date, the minimum version requirement can
|
||||||
|
# be changed, and newer features can be used unconditionally. See PR#5301.
|
||||||
|
#
|
||||||
|
|
||||||
|
if [[ ${BASH_COMPLETION_VERSINFO[0]} -ne 2 \
|
||||||
|
|| ${BASH_COMPLETION_VERSINFO[1]} -lt 8 ]]; then
|
||||||
|
echo "Incompatible version of 'bash-completion'!"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The later code relies on 'bash-completion' version 2.12, but older versions
|
||||||
|
# are still supported. Here, we provide implementations of the newer functions
|
||||||
|
# in terms of older ones, if 'bash-completion' is too old to have them.
|
||||||
|
|
||||||
|
if [[ ${BASH_COMPLETION_VERSINFO[1]} -lt 12 ]]; then
|
||||||
|
_comp_get_words() {
|
||||||
|
_get_comp_words_by_ref "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
_comp_compgen_filedir() {
|
||||||
|
_filedir "$@"
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
# Determines the beets subcommand and dispatches the completion
|
# Determines the beets subcommand and dispatches the completion
|
||||||
# accordingly.
|
# accordingly.
|
||||||
@@ -54,7 +77,7 @@ _beet_dispatch() {
|
|||||||
local cur prev cmd=
|
local cur prev cmd=
|
||||||
|
|
||||||
COMPREPLY=()
|
COMPREPLY=()
|
||||||
_get_comp_words_by_ref -n : cur prev
|
_comp_get_words -n : cur prev
|
||||||
|
|
||||||
# Look for the beets subcommand
|
# Look for the beets subcommand
|
||||||
local arg
|
local arg
|
||||||
@@ -99,7 +122,7 @@ _beet_complete() {
|
|||||||
completions="${flags___common} ${opts} ${flags}"
|
completions="${flags___common} ${opts} ${flags}"
|
||||||
COMPREPLY+=( $(compgen -W "$completions" -- $cur) )
|
COMPREPLY+=( $(compgen -W "$completions" -- $cur) )
|
||||||
else
|
else
|
||||||
_filedir
|
_comp_compgen_filedir
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,12 +137,12 @@ _beet_complete_global() {
|
|||||||
;;
|
;;
|
||||||
-l|--library|-c|--config)
|
-l|--library|-c|--config)
|
||||||
# Filename completion
|
# Filename completion
|
||||||
_filedir
|
_comp_compgen_filedir
|
||||||
return
|
return
|
||||||
;;
|
;;
|
||||||
-d|--directory)
|
-d|--directory)
|
||||||
# Directory completion
|
# Directory completion
|
||||||
_filedir -d
|
_comp_compgen_filedir -d
|
||||||
return
|
return
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
+365
-351
File diff suppressed because it is too large
Load Diff
+522
-315
@@ -16,23 +16,20 @@
|
|||||||
public resizing proxy if neither is available.
|
public resizing proxy if neither is available.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import os
|
import os
|
||||||
import os.path
|
import os.path
|
||||||
|
import platform
|
||||||
import re
|
import re
|
||||||
from tempfile import NamedTemporaryFile
|
import subprocess
|
||||||
|
from itertools import chain
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from beets import logging
|
|
||||||
from beets import util
|
|
||||||
|
|
||||||
# Resizing methods
|
from beets import logging, util
|
||||||
PIL = 1
|
from beets.util import displayable_path, get_temp_filename, syspath
|
||||||
IMAGEMAGICK = 2
|
|
||||||
WEBPROXY = 3
|
|
||||||
|
|
||||||
PROXY_URL = 'https://images.weserv.nl/'
|
PROXY_URL = "https://images.weserv.nl/"
|
||||||
|
|
||||||
log = logging.getLogger('beets')
|
log = logging.getLogger("beets")
|
||||||
|
|
||||||
|
|
||||||
def resize_url(url, maxwidth, quality=0):
|
def resize_url(url, maxwidth, quality=0):
|
||||||
@@ -40,265 +37,473 @@ def resize_url(url, maxwidth, quality=0):
|
|||||||
maxwidth (preserving aspect ratio).
|
maxwidth (preserving aspect ratio).
|
||||||
"""
|
"""
|
||||||
params = {
|
params = {
|
||||||
'url': url.replace('http://', ''),
|
"url": url.replace("http://", ""),
|
||||||
'w': maxwidth,
|
"w": maxwidth,
|
||||||
}
|
}
|
||||||
|
|
||||||
if quality > 0:
|
if quality > 0:
|
||||||
params['q'] = quality
|
params["q"] = quality
|
||||||
|
|
||||||
return '{}?{}'.format(PROXY_URL, urlencode(params))
|
return "{}?{}".format(PROXY_URL, urlencode(params))
|
||||||
|
|
||||||
|
|
||||||
def temp_file_for(path):
|
class LocalBackendNotAvailableError(Exception):
|
||||||
"""Return an unused filename with the same extension as the
|
pass
|
||||||
specified path.
|
|
||||||
"""
|
|
||||||
ext = os.path.splitext(path)[1]
|
|
||||||
with NamedTemporaryFile(suffix=util.py3_path(ext), delete=False) as f:
|
|
||||||
return util.bytestring_path(f.name)
|
|
||||||
|
|
||||||
|
|
||||||
def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
|
_NOT_AVAILABLE = object()
|
||||||
"""Resize using Python Imaging Library (PIL). Return the output path
|
|
||||||
of resized image.
|
|
||||||
"""
|
|
||||||
path_out = path_out or temp_file_for(path_in)
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
log.debug('artresizer: PIL resizing {0} to {1}',
|
|
||||||
util.displayable_path(path_in), util.displayable_path(path_out))
|
|
||||||
|
|
||||||
try:
|
class LocalBackend:
|
||||||
im = Image.open(util.syspath(path_in))
|
@classmethod
|
||||||
size = maxwidth, maxwidth
|
def available(cls):
|
||||||
im.thumbnail(size, Image.ANTIALIAS)
|
try:
|
||||||
|
cls.version()
|
||||||
|
return True
|
||||||
|
except LocalBackendNotAvailableError:
|
||||||
|
return False
|
||||||
|
|
||||||
if quality == 0:
|
|
||||||
# Use PIL's default quality.
|
|
||||||
quality = -1
|
|
||||||
|
|
||||||
# progressive=False only affects JPEGs and is the default,
|
class IMBackend(LocalBackend):
|
||||||
# but we include it here for explicitness.
|
NAME = "ImageMagick"
|
||||||
im.save(util.py3_path(path_out), quality=quality, progressive=False)
|
|
||||||
|
|
||||||
if max_filesize > 0:
|
# These fields are used as a cache for `version()`. `_legacy` indicates
|
||||||
# If maximum filesize is set, we attempt to lower the quality of
|
# whether the modern `magick` binary is available or whether to fall back
|
||||||
# jpeg conversion by a proportional amount, up to 3 attempts
|
# to the old-style `convert`, `identify`, etc. commands.
|
||||||
# First, set the maximum quality to either provided, or 95
|
_version = None
|
||||||
if quality > 0:
|
_legacy = None
|
||||||
lower_qual = quality
|
|
||||||
else:
|
|
||||||
lower_qual = 95
|
|
||||||
for i in range(5):
|
|
||||||
# 5 attempts is an abitrary choice
|
|
||||||
filesize = os.stat(util.syspath(path_out)).st_size
|
|
||||||
log.debug("PIL Pass {0} : Output size: {1}B", i, filesize)
|
|
||||||
if filesize <= max_filesize:
|
|
||||||
return path_out
|
|
||||||
# The relationship between filesize & quality will be
|
|
||||||
# image dependent.
|
|
||||||
lower_qual -= 10
|
|
||||||
# Restrict quality dropping below 10
|
|
||||||
if lower_qual < 10:
|
|
||||||
lower_qual = 10
|
|
||||||
# Use optimize flag to improve filesize decrease
|
|
||||||
im.save(util.py3_path(path_out), quality=lower_qual,
|
|
||||||
optimize=True, progressive=False)
|
|
||||||
log.warning("PIL Failed to resize file to below {0}B",
|
|
||||||
max_filesize)
|
|
||||||
return path_out
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def version(cls):
|
||||||
|
"""Obtain and cache ImageMagick version.
|
||||||
|
|
||||||
|
Raises `LocalBackendNotAvailableError` if not available.
|
||||||
|
"""
|
||||||
|
if cls._version is None:
|
||||||
|
for cmd_name, legacy in (("magick", False), ("convert", True)):
|
||||||
|
try:
|
||||||
|
out = util.command_output([cmd_name, "--version"]).stdout
|
||||||
|
except (subprocess.CalledProcessError, OSError) as exc:
|
||||||
|
log.debug("ImageMagick version check failed: {}", exc)
|
||||||
|
cls._version = _NOT_AVAILABLE
|
||||||
|
else:
|
||||||
|
if b"imagemagick" in out.lower():
|
||||||
|
pattern = rb".+ (\d+)\.(\d+)\.(\d+).*"
|
||||||
|
match = re.search(pattern, out)
|
||||||
|
if match:
|
||||||
|
cls._version = (
|
||||||
|
int(match.group(1)),
|
||||||
|
int(match.group(2)),
|
||||||
|
int(match.group(3)),
|
||||||
|
)
|
||||||
|
cls._legacy = legacy
|
||||||
|
|
||||||
|
if cls._version is _NOT_AVAILABLE:
|
||||||
|
raise LocalBackendNotAvailableError()
|
||||||
else:
|
else:
|
||||||
return path_out
|
return cls._version
|
||||||
except OSError:
|
|
||||||
log.error("PIL cannot create thumbnail for '{0}'",
|
|
||||||
util.displayable_path(path_in))
|
|
||||||
return path_in
|
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize a wrapper around ImageMagick for local image operations.
|
||||||
|
|
||||||
def im_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
|
Stores the ImageMagick version and legacy flag. If ImageMagick is not
|
||||||
"""Resize using ImageMagick.
|
available, raise an Exception.
|
||||||
|
"""
|
||||||
|
self.version()
|
||||||
|
|
||||||
Use the ``magick`` program or ``convert`` on older versions. Return
|
# Use ImageMagick's magick binary when it's available.
|
||||||
the output path of resized image.
|
# If it's not, fall back to the older, separate convert
|
||||||
"""
|
# and identify commands.
|
||||||
path_out = path_out or temp_file_for(path_in)
|
if self._legacy:
|
||||||
log.debug('artresizer: ImageMagick resizing {0} to {1}',
|
self.convert_cmd = ["convert"]
|
||||||
util.displayable_path(path_in), util.displayable_path(path_out))
|
self.identify_cmd = ["identify"]
|
||||||
|
self.compare_cmd = ["compare"]
|
||||||
|
else:
|
||||||
|
self.convert_cmd = ["magick"]
|
||||||
|
self.identify_cmd = ["magick", "identify"]
|
||||||
|
self.compare_cmd = ["magick", "compare"]
|
||||||
|
|
||||||
# "-resize WIDTHx>" shrinks images with the width larger
|
def resize(
|
||||||
# than the given width while maintaining the aspect ratio
|
self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
|
||||||
# with regards to the height.
|
):
|
||||||
# ImageMagick already seems to default to no interlace, but we include it
|
"""Resize using ImageMagick.
|
||||||
# here for the sake of explicitness.
|
|
||||||
cmd = ArtResizer.shared.im_convert_cmd + [
|
|
||||||
util.syspath(path_in, prefix=False),
|
|
||||||
'-resize', f'{maxwidth}x>',
|
|
||||||
'-interlace', 'none',
|
|
||||||
]
|
|
||||||
|
|
||||||
if quality > 0:
|
Use the ``magick`` program or ``convert`` on older versions. Return
|
||||||
cmd += ['-quality', f'{quality}']
|
the output path of resized image.
|
||||||
|
"""
|
||||||
|
if not path_out:
|
||||||
|
path_out = get_temp_filename(__name__, "resize_IM_", path_in)
|
||||||
|
|
||||||
# "-define jpeg:extent=SIZEb" sets the target filesize for imagemagick to
|
|
||||||
# SIZE in bytes.
|
|
||||||
if max_filesize > 0:
|
|
||||||
cmd += ['-define', f'jpeg:extent={max_filesize}b']
|
|
||||||
|
|
||||||
cmd.append(util.syspath(path_out, prefix=False))
|
|
||||||
|
|
||||||
try:
|
|
||||||
util.command_output(cmd)
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
log.warning('artresizer: IM convert failed for {0}',
|
|
||||||
util.displayable_path(path_in))
|
|
||||||
return path_in
|
|
||||||
|
|
||||||
return path_out
|
|
||||||
|
|
||||||
|
|
||||||
BACKEND_FUNCS = {
|
|
||||||
PIL: pil_resize,
|
|
||||||
IMAGEMAGICK: im_resize,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def pil_getsize(path_in):
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
try:
|
|
||||||
im = Image.open(util.syspath(path_in))
|
|
||||||
return im.size
|
|
||||||
except OSError as exc:
|
|
||||||
log.error("PIL could not read file {}: {}",
|
|
||||||
util.displayable_path(path_in), exc)
|
|
||||||
|
|
||||||
|
|
||||||
def im_getsize(path_in):
|
|
||||||
cmd = ArtResizer.shared.im_identify_cmd + \
|
|
||||||
['-format', '%w %h', util.syspath(path_in, prefix=False)]
|
|
||||||
|
|
||||||
try:
|
|
||||||
out = util.command_output(cmd).stdout
|
|
||||||
except subprocess.CalledProcessError as exc:
|
|
||||||
log.warning('ImageMagick size query failed')
|
|
||||||
log.debug(
|
log.debug(
|
||||||
'`convert` exited with (status {}) when '
|
"artresizer: ImageMagick resizing {0} to {1}",
|
||||||
'getting size with command {}:\n{}',
|
displayable_path(path_in),
|
||||||
exc.returncode, cmd, exc.output.strip()
|
displayable_path(path_out),
|
||||||
)
|
)
|
||||||
return
|
|
||||||
try:
|
|
||||||
return tuple(map(int, out.split(b' ')))
|
|
||||||
except IndexError:
|
|
||||||
log.warning('Could not understand IM output: {0!r}', out)
|
|
||||||
|
|
||||||
|
# "-resize WIDTHx>" shrinks images with the width larger
|
||||||
|
# than the given width while maintaining the aspect ratio
|
||||||
|
# with regards to the height.
|
||||||
|
# ImageMagick already seems to default to no interlace, but we include
|
||||||
|
# it here for the sake of explicitness.
|
||||||
|
cmd = self.convert_cmd + [
|
||||||
|
syspath(path_in, prefix=False),
|
||||||
|
"-resize",
|
||||||
|
f"{maxwidth}x>",
|
||||||
|
"-interlace",
|
||||||
|
"none",
|
||||||
|
]
|
||||||
|
|
||||||
BACKEND_GET_SIZE = {
|
if quality > 0:
|
||||||
PIL: pil_getsize,
|
cmd += ["-quality", f"{quality}"]
|
||||||
IMAGEMAGICK: im_getsize,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
# "-define jpeg:extent=SIZEb" sets the target filesize for imagemagick
|
||||||
|
# to SIZE in bytes.
|
||||||
|
if max_filesize > 0:
|
||||||
|
cmd += ["-define", f"jpeg:extent={max_filesize}b"]
|
||||||
|
|
||||||
def pil_deinterlace(path_in, path_out=None):
|
cmd.append(syspath(path_out, prefix=False))
|
||||||
path_out = path_out or temp_file_for(path_in)
|
|
||||||
from PIL import Image
|
try:
|
||||||
|
util.command_output(cmd)
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
log.warning(
|
||||||
|
"artresizer: IM convert failed for {0}",
|
||||||
|
displayable_path(path_in),
|
||||||
|
)
|
||||||
|
return path_in
|
||||||
|
|
||||||
try:
|
|
||||||
im = Image.open(util.syspath(path_in))
|
|
||||||
im.save(util.py3_path(path_out), progressive=False)
|
|
||||||
return path_out
|
return path_out
|
||||||
except IOError:
|
|
||||||
return path_in
|
|
||||||
|
|
||||||
|
def get_size(self, path_in):
|
||||||
|
cmd = self.identify_cmd + [
|
||||||
|
"-format",
|
||||||
|
"%w %h",
|
||||||
|
syspath(path_in, prefix=False),
|
||||||
|
]
|
||||||
|
|
||||||
def im_deinterlace(path_in, path_out=None):
|
try:
|
||||||
path_out = path_out or temp_file_for(path_in)
|
out = util.command_output(cmd).stdout
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
log.warning("ImageMagick size query failed")
|
||||||
|
log.debug(
|
||||||
|
"`convert` exited with (status {}) when "
|
||||||
|
"getting size with command {}:\n{}",
|
||||||
|
exc.returncode,
|
||||||
|
cmd,
|
||||||
|
exc.output.strip(),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return tuple(map(int, out.split(b" ")))
|
||||||
|
except IndexError:
|
||||||
|
log.warning("Could not understand IM output: {0!r}", out)
|
||||||
|
return None
|
||||||
|
|
||||||
cmd = ArtResizer.shared.im_convert_cmd + [
|
def deinterlace(self, path_in, path_out=None):
|
||||||
util.syspath(path_in, prefix=False),
|
if not path_out:
|
||||||
'-interlace', 'none',
|
path_out = get_temp_filename(__name__, "deinterlace_IM_", path_in)
|
||||||
util.syspath(path_out, prefix=False),
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
cmd = self.convert_cmd + [
|
||||||
util.command_output(cmd)
|
syspath(path_in, prefix=False),
|
||||||
return path_out
|
"-interlace",
|
||||||
except subprocess.CalledProcessError:
|
"none",
|
||||||
return path_in
|
syspath(path_out, prefix=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
util.command_output(cmd)
|
||||||
|
return path_out
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
# FIXME: Should probably issue a warning?
|
||||||
|
return path_in
|
||||||
|
|
||||||
DEINTERLACE_FUNCS = {
|
def get_format(self, filepath):
|
||||||
PIL: pil_deinterlace,
|
cmd = self.identify_cmd + ["-format", "%[magick]", syspath(filepath)]
|
||||||
IMAGEMAGICK: im_deinterlace,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
return util.command_output(cmd).stdout
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
# FIXME: Should probably issue a warning?
|
||||||
|
return None
|
||||||
|
|
||||||
def im_get_format(filepath):
|
def convert_format(self, source, target, deinterlaced):
|
||||||
cmd = ArtResizer.shared.im_identify_cmd + [
|
cmd = self.convert_cmd + [
|
||||||
'-format', '%[magick]',
|
syspath(source),
|
||||||
util.syspath(filepath)
|
*(["-interlace", "none"] if deinterlaced else []),
|
||||||
]
|
syspath(target),
|
||||||
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return util.command_output(cmd).stdout
|
subprocess.check_call(
|
||||||
except subprocess.CalledProcessError:
|
cmd, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL
|
||||||
return None
|
)
|
||||||
|
|
||||||
|
|
||||||
def pil_get_format(filepath):
|
|
||||||
from PIL import Image, UnidentifiedImageError
|
|
||||||
|
|
||||||
try:
|
|
||||||
with Image.open(util.syspath(filepath)) as im:
|
|
||||||
return im.format
|
|
||||||
except (ValueError, TypeError, UnidentifiedImageError, FileNotFoundError):
|
|
||||||
log.exception("failed to detect image format for {}", filepath)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
BACKEND_GET_FORMAT = {
|
|
||||||
PIL: pil_get_format,
|
|
||||||
IMAGEMAGICK: im_get_format,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def im_convert_format(source, target, deinterlaced):
|
|
||||||
cmd = ArtResizer.shared.im_convert_cmd + [
|
|
||||||
util.syspath(source),
|
|
||||||
*(["-interlace", "none"] if deinterlaced else []),
|
|
||||||
util.syspath(target),
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
|
||||||
subprocess.check_call(
|
|
||||||
cmd,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
stdout=subprocess.DEVNULL
|
|
||||||
)
|
|
||||||
return target
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
return source
|
|
||||||
|
|
||||||
|
|
||||||
def pil_convert_format(source, target, deinterlaced):
|
|
||||||
from PIL import Image, UnidentifiedImageError
|
|
||||||
|
|
||||||
try:
|
|
||||||
with Image.open(util.syspath(source)) as im:
|
|
||||||
im.save(util.py3_path(target), progressive=not deinterlaced)
|
|
||||||
return target
|
return target
|
||||||
except (ValueError, TypeError, UnidentifiedImageError, FileNotFoundError,
|
except subprocess.CalledProcessError:
|
||||||
OSError):
|
# FIXME: Should probably issue a warning?
|
||||||
log.exception("failed to convert image {} -> {}", source, target)
|
return source
|
||||||
return source
|
|
||||||
|
@property
|
||||||
|
def can_compare(self):
|
||||||
|
return self.version() > (6, 8, 7)
|
||||||
|
|
||||||
|
def compare(self, im1, im2, compare_threshold):
|
||||||
|
is_windows = platform.system() == "Windows"
|
||||||
|
|
||||||
|
# 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 = self.convert_cmd + [
|
||||||
|
syspath(im2, prefix=False),
|
||||||
|
syspath(im1, prefix=False),
|
||||||
|
"-colorspace",
|
||||||
|
"gray",
|
||||||
|
"MIFF:-",
|
||||||
|
]
|
||||||
|
compare_cmd = self.compare_cmd + [
|
||||||
|
"-define",
|
||||||
|
"phash:colorspaces=sRGB,HCLp",
|
||||||
|
"-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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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 None
|
||||||
|
|
||||||
|
# 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(im2),
|
||||||
|
displayable_path(im1),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
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 None
|
||||||
|
|
||||||
|
log.debug("ImageMagick compare score: {0}", phash_diff)
|
||||||
|
return phash_diff <= compare_threshold
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_write_metadata(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def write_metadata(self, file, metadata):
|
||||||
|
assignments = list(
|
||||||
|
chain.from_iterable(("-set", k, v) for k, v in metadata.items())
|
||||||
|
)
|
||||||
|
command = self.convert_cmd + [file, *assignments, file]
|
||||||
|
|
||||||
|
util.command_output(command)
|
||||||
|
|
||||||
|
|
||||||
BACKEND_CONVERT_IMAGE_FORMAT = {
|
class PILBackend(LocalBackend):
|
||||||
PIL: pil_convert_format,
|
NAME = "PIL"
|
||||||
IMAGEMAGICK: im_convert_format,
|
|
||||||
}
|
@classmethod
|
||||||
|
def version(cls):
|
||||||
|
try:
|
||||||
|
__import__("PIL", fromlist=["Image"])
|
||||||
|
except ImportError:
|
||||||
|
raise LocalBackendNotAvailableError()
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize a wrapper around PIL for local image operations.
|
||||||
|
|
||||||
|
If PIL is not available, raise an Exception.
|
||||||
|
"""
|
||||||
|
self.version()
|
||||||
|
|
||||||
|
def resize(
|
||||||
|
self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
|
||||||
|
):
|
||||||
|
"""Resize using Python Imaging Library (PIL). Return the output path
|
||||||
|
of resized image.
|
||||||
|
"""
|
||||||
|
if not path_out:
|
||||||
|
path_out = get_temp_filename(__name__, "resize_PIL_", path_in)
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
log.debug(
|
||||||
|
"artresizer: PIL resizing {0} to {1}",
|
||||||
|
displayable_path(path_in),
|
||||||
|
displayable_path(path_out),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
im = Image.open(syspath(path_in))
|
||||||
|
size = maxwidth, maxwidth
|
||||||
|
im.thumbnail(size, Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
if quality == 0:
|
||||||
|
# Use PIL's default quality.
|
||||||
|
quality = -1
|
||||||
|
|
||||||
|
# progressive=False only affects JPEGs and is the default,
|
||||||
|
# but we include it here for explicitness.
|
||||||
|
im.save(os.fsdecode(path_out), quality=quality, progressive=False)
|
||||||
|
|
||||||
|
if max_filesize > 0:
|
||||||
|
# If maximum filesize is set, we attempt to lower the quality
|
||||||
|
# of jpeg conversion by a proportional amount, up to 3 attempts
|
||||||
|
# First, set the maximum quality to either provided, or 95
|
||||||
|
if quality > 0:
|
||||||
|
lower_qual = quality
|
||||||
|
else:
|
||||||
|
lower_qual = 95
|
||||||
|
for i in range(5):
|
||||||
|
# 5 attempts is an arbitrary choice
|
||||||
|
filesize = os.stat(syspath(path_out)).st_size
|
||||||
|
log.debug("PIL Pass {0} : Output size: {1}B", i, filesize)
|
||||||
|
if filesize <= max_filesize:
|
||||||
|
return path_out
|
||||||
|
# The relationship between filesize & quality will be
|
||||||
|
# image dependent.
|
||||||
|
lower_qual -= 10
|
||||||
|
# Restrict quality dropping below 10
|
||||||
|
if lower_qual < 10:
|
||||||
|
lower_qual = 10
|
||||||
|
# Use optimize flag to improve filesize decrease
|
||||||
|
im.save(
|
||||||
|
os.fsdecode(path_out),
|
||||||
|
quality=lower_qual,
|
||||||
|
optimize=True,
|
||||||
|
progressive=False,
|
||||||
|
)
|
||||||
|
log.warning(
|
||||||
|
"PIL Failed to resize file to below {0}B", max_filesize
|
||||||
|
)
|
||||||
|
return path_out
|
||||||
|
|
||||||
|
else:
|
||||||
|
return path_out
|
||||||
|
except OSError:
|
||||||
|
log.error(
|
||||||
|
"PIL cannot create thumbnail for '{0}'",
|
||||||
|
displayable_path(path_in),
|
||||||
|
)
|
||||||
|
return path_in
|
||||||
|
|
||||||
|
def get_size(self, path_in):
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
try:
|
||||||
|
im = Image.open(syspath(path_in))
|
||||||
|
return im.size
|
||||||
|
except OSError as exc:
|
||||||
|
log.error(
|
||||||
|
"PIL could not read file {}: {}", displayable_path(path_in), exc
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def deinterlace(self, path_in, path_out=None):
|
||||||
|
if not path_out:
|
||||||
|
path_out = get_temp_filename(__name__, "deinterlace_PIL_", path_in)
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
try:
|
||||||
|
im = Image.open(syspath(path_in))
|
||||||
|
im.save(os.fsdecode(path_out), progressive=False)
|
||||||
|
return path_out
|
||||||
|
except OSError:
|
||||||
|
# FIXME: Should probably issue a warning?
|
||||||
|
return path_in
|
||||||
|
|
||||||
|
def get_format(self, filepath):
|
||||||
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
|
try:
|
||||||
|
with Image.open(syspath(filepath)) as im:
|
||||||
|
return im.format
|
||||||
|
except (
|
||||||
|
ValueError,
|
||||||
|
TypeError,
|
||||||
|
UnidentifiedImageError,
|
||||||
|
FileNotFoundError,
|
||||||
|
):
|
||||||
|
log.exception("failed to detect image format for {}", filepath)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def convert_format(self, source, target, deinterlaced):
|
||||||
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
|
try:
|
||||||
|
with Image.open(syspath(source)) as im:
|
||||||
|
im.save(os.fsdecode(target), progressive=not deinterlaced)
|
||||||
|
return target
|
||||||
|
except (
|
||||||
|
ValueError,
|
||||||
|
TypeError,
|
||||||
|
UnidentifiedImageError,
|
||||||
|
FileNotFoundError,
|
||||||
|
OSError,
|
||||||
|
):
|
||||||
|
log.exception("failed to convert image {} -> {}", source, target)
|
||||||
|
return source
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_compare(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def compare(self, im1, im2, compare_threshold):
|
||||||
|
# It is an error to call this when ArtResizer.can_compare is not True.
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_write_metadata(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def write_metadata(self, file, metadata):
|
||||||
|
from PIL import Image, PngImagePlugin
|
||||||
|
|
||||||
|
# FIXME: Detect and handle other file types (currently, the only user
|
||||||
|
# is the thumbnails plugin, which generates PNG images).
|
||||||
|
im = Image.open(syspath(file))
|
||||||
|
meta = PngImagePlugin.PngInfo()
|
||||||
|
for k, v in metadata.items():
|
||||||
|
meta.add_text(k, v, 0)
|
||||||
|
im.save(os.fsdecode(file), "PNG", pnginfo=meta)
|
||||||
|
|
||||||
|
|
||||||
class Shareable(type):
|
class Shareable(type):
|
||||||
@@ -319,28 +524,36 @@ class Shareable(type):
|
|||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_CLASSES = [
|
||||||
|
IMBackend,
|
||||||
|
PILBackend,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ArtResizer(metaclass=Shareable):
|
class ArtResizer(metaclass=Shareable):
|
||||||
"""A singleton class that performs image resizes.
|
"""A singleton class that performs image resizes."""
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Create a resizer object with an inferred method.
|
"""Create a resizer object with an inferred method."""
|
||||||
"""
|
# Check if a local backend is available, and store an instance of the
|
||||||
self.method = self._check_method()
|
# backend class. Otherwise, fallback to the web proxy.
|
||||||
log.debug("artresizer: method is {0}", self.method)
|
for backend_cls in BACKEND_CLASSES:
|
||||||
self.can_compare = self._can_compare()
|
try:
|
||||||
|
self.local_method = backend_cls()
|
||||||
|
log.debug(f"artresizer: method is {self.local_method.NAME}")
|
||||||
|
break
|
||||||
|
except LocalBackendNotAvailableError:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
log.debug("artresizer: method is WEBPROXY")
|
||||||
|
self.local_method = None
|
||||||
|
|
||||||
# Use ImageMagick's magick binary when it's available. If it's
|
@property
|
||||||
# not, fall back to the older, separate convert and identify
|
def method(self):
|
||||||
# commands.
|
if self.local:
|
||||||
if self.method[0] == IMAGEMAGICK:
|
return self.local_method.NAME
|
||||||
self.im_legacy = self.method[2]
|
else:
|
||||||
if self.im_legacy:
|
return "WEBPROXY"
|
||||||
self.im_convert_cmd = ['convert']
|
|
||||||
self.im_identify_cmd = ['identify']
|
|
||||||
else:
|
|
||||||
self.im_convert_cmd = ['magick']
|
|
||||||
self.im_identify_cmd = ['magick', 'identify']
|
|
||||||
|
|
||||||
def resize(
|
def resize(
|
||||||
self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
|
self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
|
||||||
@@ -351,17 +564,26 @@ class ArtResizer(metaclass=Shareable):
|
|||||||
For WEBPROXY, returns `path_in` unmodified.
|
For WEBPROXY, returns `path_in` unmodified.
|
||||||
"""
|
"""
|
||||||
if self.local:
|
if self.local:
|
||||||
func = BACKEND_FUNCS[self.method[0]]
|
return self.local_method.resize(
|
||||||
return func(maxwidth, path_in, path_out,
|
maxwidth,
|
||||||
quality=quality, max_filesize=max_filesize)
|
path_in,
|
||||||
|
path_out,
|
||||||
|
quality=quality,
|
||||||
|
max_filesize=max_filesize,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
|
# Handled by `proxy_url` already.
|
||||||
return path_in
|
return path_in
|
||||||
|
|
||||||
def deinterlace(self, path_in, path_out=None):
|
def deinterlace(self, path_in, path_out=None):
|
||||||
|
"""Deinterlace an image.
|
||||||
|
|
||||||
|
Only available locally.
|
||||||
|
"""
|
||||||
if self.local:
|
if self.local:
|
||||||
func = DEINTERLACE_FUNCS[self.method[0]]
|
return self.local_method.deinterlace(path_in, path_out)
|
||||||
return func(path_in, path_out)
|
|
||||||
else:
|
else:
|
||||||
|
# FIXME: Should probably issue a warning?
|
||||||
return path_in
|
return path_in
|
||||||
|
|
||||||
def proxy_url(self, maxwidth, url, quality=0):
|
def proxy_url(self, maxwidth, url, quality=0):
|
||||||
@@ -370,6 +592,7 @@ class ArtResizer(metaclass=Shareable):
|
|||||||
Otherwise, the URL is returned unmodified.
|
Otherwise, the URL is returned unmodified.
|
||||||
"""
|
"""
|
||||||
if self.local:
|
if self.local:
|
||||||
|
# Going to be handled by `resize()`.
|
||||||
return url
|
return url
|
||||||
else:
|
else:
|
||||||
return resize_url(url, maxwidth, quality)
|
return resize_url(url, maxwidth, quality)
|
||||||
@@ -379,7 +602,7 @@ class ArtResizer(metaclass=Shareable):
|
|||||||
"""A boolean indicating whether the resizing method is performed
|
"""A boolean indicating whether the resizing method is performed
|
||||||
locally (i.e., PIL or ImageMagick).
|
locally (i.e., PIL or ImageMagick).
|
||||||
"""
|
"""
|
||||||
return self.method[0] in BACKEND_FUNCS
|
return self.local_method is not None
|
||||||
|
|
||||||
def get_size(self, path_in):
|
def get_size(self, path_in):
|
||||||
"""Return the size of an image file as an int couple (width, height)
|
"""Return the size of an image file as an int couple (width, height)
|
||||||
@@ -388,8 +611,10 @@ class ArtResizer(metaclass=Shareable):
|
|||||||
Only available locally.
|
Only available locally.
|
||||||
"""
|
"""
|
||||||
if self.local:
|
if self.local:
|
||||||
func = BACKEND_GET_SIZE[self.method[0]]
|
return self.local_method.get_size(path_in)
|
||||||
return func(path_in)
|
else:
|
||||||
|
# FIXME: Should probably issue a warning?
|
||||||
|
return path_in
|
||||||
|
|
||||||
def get_format(self, path_in):
|
def get_format(self, path_in):
|
||||||
"""Returns the format of the image as a string.
|
"""Returns the format of the image as a string.
|
||||||
@@ -397,8 +622,10 @@ class ArtResizer(metaclass=Shareable):
|
|||||||
Only available locally.
|
Only available locally.
|
||||||
"""
|
"""
|
||||||
if self.local:
|
if self.local:
|
||||||
func = BACKEND_GET_FORMAT[self.method[0]]
|
return self.local_method.get_format(path_in)
|
||||||
return func(path_in)
|
else:
|
||||||
|
# FIXME: Should probably issue a warning?
|
||||||
|
return None
|
||||||
|
|
||||||
def reformat(self, path_in, new_format, deinterlaced=True):
|
def reformat(self, path_in, new_format, deinterlaced=True):
|
||||||
"""Converts image to desired format, updating its extension, but
|
"""Converts image to desired format, updating its extension, but
|
||||||
@@ -407,86 +634,66 @@ class ArtResizer(metaclass=Shareable):
|
|||||||
Only available locally.
|
Only available locally.
|
||||||
"""
|
"""
|
||||||
if not self.local:
|
if not self.local:
|
||||||
|
# FIXME: Should probably issue a warning?
|
||||||
return path_in
|
return path_in
|
||||||
|
|
||||||
new_format = new_format.lower()
|
new_format = new_format.lower()
|
||||||
# A nonexhaustive map of image "types" to extensions overrides
|
# A nonexhaustive map of image "types" to extensions overrides
|
||||||
new_format = {
|
new_format = {
|
||||||
'jpeg': 'jpg',
|
"jpeg": "jpg",
|
||||||
}.get(new_format, new_format)
|
}.get(new_format, new_format)
|
||||||
|
|
||||||
fname, ext = os.path.splitext(path_in)
|
fname, ext = os.path.splitext(path_in)
|
||||||
path_new = fname + b'.' + new_format.encode('utf8')
|
path_new = fname + b"." + new_format.encode("utf8")
|
||||||
func = BACKEND_CONVERT_IMAGE_FORMAT[self.method[0]]
|
|
||||||
|
|
||||||
# allows the exception to propagate, while still making sure a changed
|
# allows the exception to propagate, while still making sure a changed
|
||||||
# file path was removed
|
# file path was removed
|
||||||
result_path = path_in
|
result_path = path_in
|
||||||
try:
|
try:
|
||||||
result_path = func(path_in, path_new, deinterlaced)
|
result_path = self.local_method.convert_format(
|
||||||
|
path_in, path_new, deinterlaced
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
if result_path != path_in:
|
if result_path != path_in:
|
||||||
os.unlink(path_in)
|
os.unlink(path_in)
|
||||||
return result_path
|
return result_path
|
||||||
|
|
||||||
def _can_compare(self):
|
@property
|
||||||
|
def can_compare(self):
|
||||||
"""A boolean indicating whether image comparison is available"""
|
"""A boolean indicating whether image comparison is available"""
|
||||||
|
|
||||||
return self.method[0] == IMAGEMAGICK and self.method[1] > (6, 8, 7)
|
if self.local:
|
||||||
|
return self.local_method.can_compare
|
||||||
@staticmethod
|
|
||||||
def _check_method():
|
|
||||||
"""Return a tuple indicating an available method and its version.
|
|
||||||
|
|
||||||
The result has at least two elements:
|
|
||||||
- The method, eitehr WEBPROXY, PIL, or IMAGEMAGICK.
|
|
||||||
- The version.
|
|
||||||
|
|
||||||
If the method is IMAGEMAGICK, there is also a third element: a
|
|
||||||
bool flag indicating whether to use the `magick` binary or
|
|
||||||
legacy single-purpose executables (`convert`, `identify`, etc.)
|
|
||||||
"""
|
|
||||||
version = get_im_version()
|
|
||||||
if version:
|
|
||||||
version, legacy = version
|
|
||||||
return IMAGEMAGICK, version, legacy
|
|
||||||
|
|
||||||
version = get_pil_version()
|
|
||||||
if version:
|
|
||||||
return PIL, version
|
|
||||||
|
|
||||||
return WEBPROXY, (0)
|
|
||||||
|
|
||||||
|
|
||||||
def get_im_version():
|
|
||||||
"""Get the ImageMagick version and legacy flag as a pair. Or return
|
|
||||||
None if ImageMagick is not available.
|
|
||||||
"""
|
|
||||||
for cmd_name, legacy in ((['magick'], False), (['convert'], True)):
|
|
||||||
cmd = cmd_name + ['--version']
|
|
||||||
|
|
||||||
try:
|
|
||||||
out = util.command_output(cmd).stdout
|
|
||||||
except (subprocess.CalledProcessError, OSError) as exc:
|
|
||||||
log.debug('ImageMagick version check failed: {}', exc)
|
|
||||||
else:
|
else:
|
||||||
if b'imagemagick' in out.lower():
|
return False
|
||||||
pattern = br".+ (\d+)\.(\d+)\.(\d+).*"
|
|
||||||
match = re.search(pattern, out)
|
|
||||||
if match:
|
|
||||||
version = (int(match.group(1)),
|
|
||||||
int(match.group(2)),
|
|
||||||
int(match.group(3)))
|
|
||||||
return version, legacy
|
|
||||||
|
|
||||||
return None
|
def compare(self, im1, im2, compare_threshold):
|
||||||
|
"""Return a boolean indicating whether two images are similar.
|
||||||
|
|
||||||
|
Only available locally.
|
||||||
|
"""
|
||||||
|
if self.local:
|
||||||
|
return self.local_method.compare(im1, im2, compare_threshold)
|
||||||
|
else:
|
||||||
|
# FIXME: Should probably issue a warning?
|
||||||
|
return None
|
||||||
|
|
||||||
def get_pil_version():
|
@property
|
||||||
"""Get the PIL/Pillow version, or None if it is unavailable.
|
def can_write_metadata(self):
|
||||||
"""
|
"""A boolean indicating whether writing image metadata is supported."""
|
||||||
try:
|
|
||||||
__import__('PIL', fromlist=['Image'])
|
if self.local:
|
||||||
return (0,)
|
return self.local_method.can_write_metadata
|
||||||
except ImportError:
|
else:
|
||||||
return None
|
return False
|
||||||
|
|
||||||
|
def write_metadata(self, file, metadata):
|
||||||
|
"""Write key-value metadata to the image file.
|
||||||
|
|
||||||
|
Only available locally. Currently, expects the image to be a PNG file.
|
||||||
|
"""
|
||||||
|
if self.local:
|
||||||
|
self.local_method.write_metadata(file, metadata)
|
||||||
|
else:
|
||||||
|
# FIXME: Should probably issue a warning?
|
||||||
|
pass
|
||||||
|
|||||||
+60
-41
@@ -6,23 +6,24 @@ asyncore.
|
|||||||
Bluelet: easy concurrency without all the messy parallelism.
|
Bluelet: easy concurrency without all the messy parallelism.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import socket
|
|
||||||
import select
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
import errno
|
|
||||||
import traceback
|
|
||||||
import time
|
|
||||||
import collections
|
import collections
|
||||||
|
import errno
|
||||||
|
import select
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
import types
|
||||||
|
|
||||||
# Basic events used for thread scheduling.
|
# Basic events used for thread scheduling.
|
||||||
|
|
||||||
|
|
||||||
class Event:
|
class Event:
|
||||||
"""Just a base class identifying Bluelet events. An event is an
|
"""Just a base class identifying Bluelet events. An event is an
|
||||||
object yielded from a Bluelet thread coroutine to suspend operation
|
object yielded from a Bluelet thread coroutine to suspend operation
|
||||||
and communicate with the scheduler.
|
and communicate with the scheduler.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ class WaitableEvent(Event):
|
|||||||
waited for using a select() call. That is, it's an event with an
|
waited for using a select() call. That is, it's an event with an
|
||||||
associated file descriptor.
|
associated file descriptor.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def waitables(self):
|
def waitables(self):
|
||||||
"""Return "waitable" objects to pass to select(). Should return
|
"""Return "waitable" objects to pass to select(). Should return
|
||||||
three iterables for input readiness, output readiness, and
|
three iterables for input readiness, output readiness, and
|
||||||
@@ -48,18 +50,21 @@ class WaitableEvent(Event):
|
|||||||
|
|
||||||
class ValueEvent(Event):
|
class ValueEvent(Event):
|
||||||
"""An event that does nothing but return a fixed value."""
|
"""An event that does nothing but return a fixed value."""
|
||||||
|
|
||||||
def __init__(self, value):
|
def __init__(self, value):
|
||||||
self.value = value
|
self.value = value
|
||||||
|
|
||||||
|
|
||||||
class ExceptionEvent(Event):
|
class ExceptionEvent(Event):
|
||||||
"""Raise an exception at the yield point. Used internally."""
|
"""Raise an exception at the yield point. Used internally."""
|
||||||
|
|
||||||
def __init__(self, exc_info):
|
def __init__(self, exc_info):
|
||||||
self.exc_info = exc_info
|
self.exc_info = exc_info
|
||||||
|
|
||||||
|
|
||||||
class SpawnEvent(Event):
|
class SpawnEvent(Event):
|
||||||
"""Add a new coroutine thread to the scheduler."""
|
"""Add a new coroutine thread to the scheduler."""
|
||||||
|
|
||||||
def __init__(self, coro):
|
def __init__(self, coro):
|
||||||
self.spawned = coro
|
self.spawned = coro
|
||||||
|
|
||||||
@@ -68,12 +73,14 @@ class JoinEvent(Event):
|
|||||||
"""Suspend the thread until the specified child thread has
|
"""Suspend the thread until the specified child thread has
|
||||||
completed.
|
completed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, child):
|
def __init__(self, child):
|
||||||
self.child = child
|
self.child = child
|
||||||
|
|
||||||
|
|
||||||
class KillEvent(Event):
|
class KillEvent(Event):
|
||||||
"""Unschedule a child thread."""
|
"""Unschedule a child thread."""
|
||||||
|
|
||||||
def __init__(self, child):
|
def __init__(self, child):
|
||||||
self.child = child
|
self.child = child
|
||||||
|
|
||||||
@@ -83,6 +90,7 @@ class DelegationEvent(Event):
|
|||||||
once the child thread finished, return control to the parent
|
once the child thread finished, return control to the parent
|
||||||
thread.
|
thread.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, coro):
|
def __init__(self, coro):
|
||||||
self.spawned = coro
|
self.spawned = coro
|
||||||
|
|
||||||
@@ -91,13 +99,14 @@ class ReturnEvent(Event):
|
|||||||
"""Return a value the current thread's delegator at the point of
|
"""Return a value the current thread's delegator at the point of
|
||||||
delegation. Ends the current (delegate) thread.
|
delegation. Ends the current (delegate) thread.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, value):
|
def __init__(self, value):
|
||||||
self.value = value
|
self.value = value
|
||||||
|
|
||||||
|
|
||||||
class SleepEvent(WaitableEvent):
|
class SleepEvent(WaitableEvent):
|
||||||
"""Suspend the thread for a given duration.
|
"""Suspend the thread for a given duration."""
|
||||||
"""
|
|
||||||
def __init__(self, duration):
|
def __init__(self, duration):
|
||||||
self.wakeup_time = time.time() + duration
|
self.wakeup_time = time.time() + duration
|
||||||
|
|
||||||
@@ -107,6 +116,7 @@ class SleepEvent(WaitableEvent):
|
|||||||
|
|
||||||
class ReadEvent(WaitableEvent):
|
class ReadEvent(WaitableEvent):
|
||||||
"""Reads from a file-like object."""
|
"""Reads from a file-like object."""
|
||||||
|
|
||||||
def __init__(self, fd, bufsize):
|
def __init__(self, fd, bufsize):
|
||||||
self.fd = fd
|
self.fd = fd
|
||||||
self.bufsize = bufsize
|
self.bufsize = bufsize
|
||||||
@@ -120,6 +130,7 @@ class ReadEvent(WaitableEvent):
|
|||||||
|
|
||||||
class WriteEvent(WaitableEvent):
|
class WriteEvent(WaitableEvent):
|
||||||
"""Writes to a file-like object."""
|
"""Writes to a file-like object."""
|
||||||
|
|
||||||
def __init__(self, fd, data):
|
def __init__(self, fd, data):
|
||||||
self.fd = fd
|
self.fd = fd
|
||||||
self.data = data
|
self.data = data
|
||||||
@@ -133,6 +144,7 @@ class WriteEvent(WaitableEvent):
|
|||||||
|
|
||||||
# Core logic for executing and scheduling threads.
|
# Core logic for executing and scheduling threads.
|
||||||
|
|
||||||
|
|
||||||
def _event_select(events):
|
def _event_select(events):
|
||||||
"""Perform a select() over all the Events provided, returning the
|
"""Perform a select() over all the Events provided, returning the
|
||||||
ones ready to be fired. Only WaitableEvents (including SleepEvents)
|
ones ready to be fired. Only WaitableEvents (including SleepEvents)
|
||||||
@@ -154,11 +166,11 @@ def _event_select(events):
|
|||||||
wlist += w
|
wlist += w
|
||||||
xlist += x
|
xlist += x
|
||||||
for waitable in r:
|
for waitable in r:
|
||||||
waitable_to_event[('r', waitable)] = event
|
waitable_to_event[("r", waitable)] = event
|
||||||
for waitable in w:
|
for waitable in w:
|
||||||
waitable_to_event[('w', waitable)] = event
|
waitable_to_event[("w", waitable)] = event
|
||||||
for waitable in x:
|
for waitable in x:
|
||||||
waitable_to_event[('x', waitable)] = event
|
waitable_to_event[("x", waitable)] = event
|
||||||
|
|
||||||
# If we have a any sleeping threads, determine how long to sleep.
|
# If we have a any sleeping threads, determine how long to sleep.
|
||||||
if earliest_wakeup:
|
if earliest_wakeup:
|
||||||
@@ -177,11 +189,11 @@ def _event_select(events):
|
|||||||
# Gather ready events corresponding to the ready waitables.
|
# Gather ready events corresponding to the ready waitables.
|
||||||
ready_events = set()
|
ready_events = set()
|
||||||
for ready in rready:
|
for ready in rready:
|
||||||
ready_events.add(waitable_to_event[('r', ready)])
|
ready_events.add(waitable_to_event[("r", ready)])
|
||||||
for ready in wready:
|
for ready in wready:
|
||||||
ready_events.add(waitable_to_event[('w', ready)])
|
ready_events.add(waitable_to_event[("w", ready)])
|
||||||
for ready in xready:
|
for ready in xready:
|
||||||
ready_events.add(waitable_to_event[('x', ready)])
|
ready_events.add(waitable_to_event[("x", ready)])
|
||||||
|
|
||||||
# Gather any finished sleeps.
|
# Gather any finished sleeps.
|
||||||
for event in events:
|
for event in events:
|
||||||
@@ -207,6 +219,7 @@ class Delegated(Event):
|
|||||||
"""Placeholder indicating that a thread has delegated execution to a
|
"""Placeholder indicating that a thread has delegated execution to a
|
||||||
different thread.
|
different thread.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, child):
|
def __init__(self, child):
|
||||||
self.child = child
|
self.child = child
|
||||||
|
|
||||||
@@ -277,8 +290,7 @@ def run(root_coro):
|
|||||||
threads[coro] = next_event
|
threads[coro] = next_event
|
||||||
|
|
||||||
def kill_thread(coro):
|
def kill_thread(coro):
|
||||||
"""Unschedule this thread and its (recursive) delegates.
|
"""Unschedule this thread and its (recursive) delegates."""
|
||||||
"""
|
|
||||||
# Collect all coroutines in the delegation stack.
|
# Collect all coroutines in the delegation stack.
|
||||||
coros = [coro]
|
coros = [coro]
|
||||||
while isinstance(threads[coro], Delegated):
|
while isinstance(threads[coro], Delegated):
|
||||||
@@ -338,12 +350,16 @@ def run(root_coro):
|
|||||||
try:
|
try:
|
||||||
value = event.fire()
|
value = event.fire()
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
if isinstance(exc.args, tuple) and \
|
if (
|
||||||
exc.args[0] == errno.EPIPE:
|
isinstance(exc.args, tuple)
|
||||||
|
and exc.args[0] == errno.EPIPE
|
||||||
|
):
|
||||||
# Broken pipe. Remote host disconnected.
|
# Broken pipe. Remote host disconnected.
|
||||||
pass
|
pass
|
||||||
elif isinstance(exc.args, tuple) and \
|
elif (
|
||||||
exc.args[0] == errno.ECONNRESET:
|
isinstance(exc.args, tuple)
|
||||||
|
and exc.args[0] == errno.ECONNRESET
|
||||||
|
):
|
||||||
# Connection was reset by peer.
|
# Connection was reset by peer.
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
@@ -382,16 +398,16 @@ def run(root_coro):
|
|||||||
|
|
||||||
# Sockets and their associated events.
|
# Sockets and their associated events.
|
||||||
|
|
||||||
|
|
||||||
class SocketClosedError(Exception):
|
class SocketClosedError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Listener:
|
class Listener:
|
||||||
"""A socket wrapper object for listening sockets.
|
"""A socket wrapper object for listening sockets."""
|
||||||
"""
|
|
||||||
def __init__(self, host, port):
|
def __init__(self, host, port):
|
||||||
"""Create a listening socket on the given hostname and port.
|
"""Create a listening socket on the given hostname and port."""
|
||||||
"""
|
|
||||||
self._closed = False
|
self._closed = False
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
@@ -410,19 +426,18 @@ class Listener:
|
|||||||
return AcceptEvent(self)
|
return AcceptEvent(self)
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""Immediately close the listening socket. (Not an event.)
|
"""Immediately close the listening socket. (Not an event.)"""
|
||||||
"""
|
|
||||||
self._closed = True
|
self._closed = True
|
||||||
self.sock.close()
|
self.sock.close()
|
||||||
|
|
||||||
|
|
||||||
class Connection:
|
class Connection:
|
||||||
"""A socket wrapper object for connected sockets.
|
"""A socket wrapper object for connected sockets."""
|
||||||
"""
|
|
||||||
def __init__(self, sock, addr):
|
def __init__(self, sock, addr):
|
||||||
self.sock = sock
|
self.sock = sock
|
||||||
self.addr = addr
|
self.addr = addr
|
||||||
self._buf = b''
|
self._buf = b""
|
||||||
self._closed = False
|
self._closed = False
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
@@ -473,7 +488,7 @@ class Connection:
|
|||||||
self._buf += data
|
self._buf += data
|
||||||
else:
|
else:
|
||||||
line = self._buf
|
line = self._buf
|
||||||
self._buf = b''
|
self._buf = b""
|
||||||
yield ReturnEvent(line)
|
yield ReturnEvent(line)
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -482,6 +497,7 @@ class AcceptEvent(WaitableEvent):
|
|||||||
"""An event for Listener objects (listening sockets) that suspends
|
"""An event for Listener objects (listening sockets) that suspends
|
||||||
execution until the socket gets a connection.
|
execution until the socket gets a connection.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, listener):
|
def __init__(self, listener):
|
||||||
self.listener = listener
|
self.listener = listener
|
||||||
|
|
||||||
@@ -497,6 +513,7 @@ class ReceiveEvent(WaitableEvent):
|
|||||||
"""An event for Connection objects (connected sockets) for
|
"""An event for Connection objects (connected sockets) for
|
||||||
asynchronously reading data.
|
asynchronously reading data.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, conn, bufsize):
|
def __init__(self, conn, bufsize):
|
||||||
self.conn = conn
|
self.conn = conn
|
||||||
self.bufsize = bufsize
|
self.bufsize = bufsize
|
||||||
@@ -512,6 +529,7 @@ class SendEvent(WaitableEvent):
|
|||||||
"""An event for Connection objects (connected sockets) for
|
"""An event for Connection objects (connected sockets) for
|
||||||
asynchronously writing data.
|
asynchronously writing data.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, conn, data, sendall=False):
|
def __init__(self, conn, data, sendall=False):
|
||||||
self.conn = conn
|
self.conn = conn
|
||||||
self.data = data
|
self.data = data
|
||||||
@@ -530,9 +548,9 @@ class SendEvent(WaitableEvent):
|
|||||||
# Public interface for threads; each returns an event object that
|
# Public interface for threads; each returns an event object that
|
||||||
# can immediately be "yield"ed.
|
# can immediately be "yield"ed.
|
||||||
|
|
||||||
|
|
||||||
def null():
|
def null():
|
||||||
"""Event: yield to the scheduler without doing anything special.
|
"""Event: yield to the scheduler without doing anything special."""
|
||||||
"""
|
|
||||||
return ValueEvent(None)
|
return ValueEvent(None)
|
||||||
|
|
||||||
|
|
||||||
@@ -541,7 +559,7 @@ def spawn(coro):
|
|||||||
and child coroutines run concurrently.
|
and child coroutines run concurrently.
|
||||||
"""
|
"""
|
||||||
if not isinstance(coro, types.GeneratorType):
|
if not isinstance(coro, types.GeneratorType):
|
||||||
raise ValueError('%s is not a coroutine' % coro)
|
raise ValueError("%s is not a coroutine" % coro)
|
||||||
return SpawnEvent(coro)
|
return SpawnEvent(coro)
|
||||||
|
|
||||||
|
|
||||||
@@ -551,7 +569,7 @@ def call(coro):
|
|||||||
returns a value using end(), then this event returns that value.
|
returns a value using end(), then this event returns that value.
|
||||||
"""
|
"""
|
||||||
if not isinstance(coro, types.GeneratorType):
|
if not isinstance(coro, types.GeneratorType):
|
||||||
raise ValueError('%s is not a coroutine' % coro)
|
raise ValueError("%s is not a coroutine" % coro)
|
||||||
return DelegationEvent(coro)
|
return DelegationEvent(coro)
|
||||||
|
|
||||||
|
|
||||||
@@ -573,7 +591,8 @@ def read(fd, bufsize=None):
|
|||||||
if not data:
|
if not data:
|
||||||
break
|
break
|
||||||
buf.append(data)
|
buf.append(data)
|
||||||
yield ReturnEvent(''.join(buf))
|
yield ReturnEvent("".join(buf))
|
||||||
|
|
||||||
return DelegationEvent(reader())
|
return DelegationEvent(reader())
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -595,8 +614,7 @@ def connect(host, port):
|
|||||||
|
|
||||||
|
|
||||||
def sleep(duration):
|
def sleep(duration):
|
||||||
"""Event: suspend the thread for ``duration`` seconds.
|
"""Event: suspend the thread for ``duration`` seconds."""
|
||||||
"""
|
|
||||||
return SleepEvent(duration)
|
return SleepEvent(duration)
|
||||||
|
|
||||||
|
|
||||||
@@ -608,19 +626,20 @@ def join(coro):
|
|||||||
|
|
||||||
|
|
||||||
def kill(coro):
|
def kill(coro):
|
||||||
"""Halt the execution of a different `spawn`ed thread.
|
"""Halt the execution of a different `spawn`ed thread."""
|
||||||
"""
|
|
||||||
return KillEvent(coro)
|
return KillEvent(coro)
|
||||||
|
|
||||||
|
|
||||||
# Convenience function for running socket servers.
|
# Convenience function for running socket servers.
|
||||||
|
|
||||||
|
|
||||||
def server(host, port, func):
|
def server(host, port, func):
|
||||||
"""A coroutine that runs a network server. Host and port specify the
|
"""A coroutine that runs a network server. Host and port specify the
|
||||||
listening address. func should be a coroutine that takes a single
|
listening address. func should be a coroutine that takes a single
|
||||||
parameter, a Connection object. The coroutine is invoked for every
|
parameter, a Connection object. The coroutine is invoked for every
|
||||||
incoming connection on the listening socket.
|
incoming connection on the listening socket.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def handler(conn):
|
def handler(conn):
|
||||||
try:
|
try:
|
||||||
yield func(conn)
|
yield func(conn)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class OrderedEnum(Enum):
|
|||||||
"""
|
"""
|
||||||
An Enum subclass that allows comparison of members.
|
An Enum subclass that allows comparison of members.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __ge__(self, other):
|
def __ge__(self, other):
|
||||||
if self.__class__ is other.__class__:
|
if self.__class__ is other.__class__:
|
||||||
return self.value >= other.value
|
return self.value >= other.value
|
||||||
|
|||||||
+105
-116
@@ -27,22 +27,21 @@ engine like Jinja2 or Mustache.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
import re
|
|
||||||
import ast
|
import ast
|
||||||
import dis
|
import dis
|
||||||
import types
|
|
||||||
import sys
|
|
||||||
import functools
|
import functools
|
||||||
|
import re
|
||||||
|
import types
|
||||||
|
|
||||||
SYMBOL_DELIM = '$'
|
SYMBOL_DELIM = "$"
|
||||||
FUNC_DELIM = '%'
|
FUNC_DELIM = "%"
|
||||||
GROUP_OPEN = '{'
|
GROUP_OPEN = "{"
|
||||||
GROUP_CLOSE = '}'
|
GROUP_CLOSE = "}"
|
||||||
ARG_SEP = ','
|
ARG_SEP = ","
|
||||||
ESCAPE_CHAR = '$'
|
ESCAPE_CHAR = "$"
|
||||||
|
|
||||||
VARIABLE_PREFIX = '__var_'
|
VARIABLE_PREFIX = "__var_"
|
||||||
FUNCTION_PREFIX = '__func_'
|
FUNCTION_PREFIX = "__func_"
|
||||||
|
|
||||||
|
|
||||||
class Environment:
|
class Environment:
|
||||||
@@ -57,10 +56,6 @@ class Environment:
|
|||||||
|
|
||||||
# Code generation helpers.
|
# Code generation helpers.
|
||||||
|
|
||||||
def ex_lvalue(name):
|
|
||||||
"""A variable load expression."""
|
|
||||||
return ast.Name(name, ast.Store())
|
|
||||||
|
|
||||||
|
|
||||||
def ex_rvalue(name):
|
def ex_rvalue(name):
|
||||||
"""A variable store expression."""
|
"""A variable store expression."""
|
||||||
@@ -74,15 +69,6 @@ def ex_literal(val):
|
|||||||
return ast.Constant(val)
|
return ast.Constant(val)
|
||||||
|
|
||||||
|
|
||||||
def ex_varassign(name, expr):
|
|
||||||
"""Assign an expression into a single variable. The expression may
|
|
||||||
either be an `ast.expr` object or a value to be used as a literal.
|
|
||||||
"""
|
|
||||||
if not isinstance(expr, ast.expr):
|
|
||||||
expr = ex_literal(expr)
|
|
||||||
return ast.Assign([ex_lvalue(name)], expr)
|
|
||||||
|
|
||||||
|
|
||||||
def ex_call(func, args):
|
def ex_call(func, args):
|
||||||
"""A function-call expression with only positional parameters. The
|
"""A function-call expression with only positional parameters. The
|
||||||
function may be an expression or the name of a function. Each
|
function may be an expression or the name of a function. Each
|
||||||
@@ -99,19 +85,18 @@ def ex_call(func, args):
|
|||||||
return ast.Call(func, args, [])
|
return ast.Call(func, args, [])
|
||||||
|
|
||||||
|
|
||||||
def compile_func(arg_names, statements, name='_the_func', debug=False):
|
def compile_func(arg_names, statements, name="_the_func", debug=False):
|
||||||
"""Compile a list of statements as the body of a function and return
|
"""Compile a list of statements as the body of a function and return
|
||||||
the resulting Python function. If `debug`, then print out the
|
the resulting Python function. If `debug`, then print out the
|
||||||
bytecode of the compiled function.
|
bytecode of the compiled function.
|
||||||
"""
|
"""
|
||||||
args_fields = {
|
args_fields = {
|
||||||
'args': [ast.arg(arg=n, annotation=None) for n in arg_names],
|
"args": [ast.arg(arg=n, annotation=None) for n in arg_names],
|
||||||
'kwonlyargs': [],
|
"kwonlyargs": [],
|
||||||
'kw_defaults': [],
|
"kw_defaults": [],
|
||||||
'defaults': [ex_literal(None) for _ in arg_names],
|
"defaults": [ex_literal(None) for _ in arg_names],
|
||||||
}
|
}
|
||||||
if 'posonlyargs' in ast.arguments._fields: # Added in Python 3.8.
|
args_fields["posonlyargs"] = []
|
||||||
args_fields['posonlyargs'] = []
|
|
||||||
args = ast.arguments(**args_fields)
|
args = ast.arguments(**args_fields)
|
||||||
|
|
||||||
func_def = ast.FunctionDef(
|
func_def = ast.FunctionDef(
|
||||||
@@ -123,14 +108,11 @@ def compile_func(arg_names, statements, name='_the_func', debug=False):
|
|||||||
|
|
||||||
# The ast.Module signature changed in 3.8 to accept a list of types to
|
# The ast.Module signature changed in 3.8 to accept a list of types to
|
||||||
# ignore.
|
# ignore.
|
||||||
if sys.version_info >= (3, 8):
|
mod = ast.Module([func_def], [])
|
||||||
mod = ast.Module([func_def], [])
|
|
||||||
else:
|
|
||||||
mod = ast.Module([func_def])
|
|
||||||
|
|
||||||
ast.fix_missing_locations(mod)
|
ast.fix_missing_locations(mod)
|
||||||
|
|
||||||
prog = compile(mod, '<generated>', 'exec')
|
prog = compile(mod, "<generated>", "exec")
|
||||||
|
|
||||||
# Debug: show bytecode.
|
# Debug: show bytecode.
|
||||||
if debug:
|
if debug:
|
||||||
@@ -146,6 +128,7 @@ def compile_func(arg_names, statements, name='_the_func', debug=False):
|
|||||||
|
|
||||||
# AST nodes for the template language.
|
# AST nodes for the template language.
|
||||||
|
|
||||||
|
|
||||||
class Symbol:
|
class Symbol:
|
||||||
"""A variable-substitution symbol in a template."""
|
"""A variable-substitution symbol in a template."""
|
||||||
|
|
||||||
@@ -154,7 +137,7 @@ class Symbol:
|
|||||||
self.original = original
|
self.original = original
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return 'Symbol(%s)' % repr(self.ident)
|
return "Symbol(%s)" % repr(self.ident)
|
||||||
|
|
||||||
def evaluate(self, env):
|
def evaluate(self, env):
|
||||||
"""Evaluate the symbol in the environment, returning a Unicode
|
"""Evaluate the symbol in the environment, returning a Unicode
|
||||||
@@ -183,8 +166,9 @@ class Call:
|
|||||||
self.original = original
|
self.original = original
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return 'Call({}, {}, {})'.format(repr(self.ident), repr(self.args),
|
return "Call({}, {}, {})".format(
|
||||||
repr(self.original))
|
repr(self.ident), repr(self.args), repr(self.original)
|
||||||
|
)
|
||||||
|
|
||||||
def evaluate(self, env):
|
def evaluate(self, env):
|
||||||
"""Evaluate the function call in the environment, returning a
|
"""Evaluate the function call in the environment, returning a
|
||||||
@@ -197,7 +181,7 @@ class Call:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# Function raised exception! Maybe inlining the name of
|
# Function raised exception! Maybe inlining the name of
|
||||||
# the exception will help debug.
|
# the exception will help debug.
|
||||||
return '<%s>' % str(exc)
|
return "<%s>" % str(exc)
|
||||||
return str(out)
|
return str(out)
|
||||||
else:
|
else:
|
||||||
return self.original
|
return self.original
|
||||||
@@ -215,21 +199,22 @@ class Call:
|
|||||||
|
|
||||||
# Create a subexpression that joins the result components of
|
# Create a subexpression that joins the result components of
|
||||||
# the arguments.
|
# the arguments.
|
||||||
arg_exprs.append(ex_call(
|
arg_exprs.append(
|
||||||
ast.Attribute(ex_literal(''), 'join', ast.Load()),
|
ex_call(
|
||||||
[ex_call(
|
ast.Attribute(ex_literal(""), "join", ast.Load()),
|
||||||
'map',
|
|
||||||
[
|
[
|
||||||
ex_rvalue(str.__name__),
|
ex_call(
|
||||||
ast.List(subexprs, ast.Load()),
|
"map",
|
||||||
]
|
[
|
||||||
)],
|
ex_rvalue(str.__name__),
|
||||||
))
|
ast.List(subexprs, ast.Load()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
subexpr_call = ex_call(
|
subexpr_call = ex_call(FUNCTION_PREFIX + self.ident, arg_exprs)
|
||||||
FUNCTION_PREFIX + self.ident,
|
|
||||||
arg_exprs
|
|
||||||
)
|
|
||||||
return [subexpr_call], varnames, funcnames
|
return [subexpr_call], varnames, funcnames
|
||||||
|
|
||||||
|
|
||||||
@@ -242,7 +227,7 @@ class Expression:
|
|||||||
self.parts = parts
|
self.parts = parts
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return 'Expression(%s)' % (repr(self.parts))
|
return "Expression(%s)" % (repr(self.parts))
|
||||||
|
|
||||||
def evaluate(self, env):
|
def evaluate(self, env):
|
||||||
"""Evaluate the entire expression in the environment, returning
|
"""Evaluate the entire expression in the environment, returning
|
||||||
@@ -254,7 +239,7 @@ class Expression:
|
|||||||
out.append(part)
|
out.append(part)
|
||||||
else:
|
else:
|
||||||
out.append(part.evaluate(env))
|
out.append(part.evaluate(env))
|
||||||
return ''.join(map(str, out))
|
return "".join(map(str, out))
|
||||||
|
|
||||||
def translate(self):
|
def translate(self):
|
||||||
"""Compile the expression to a list of Python AST expressions, a
|
"""Compile the expression to a list of Python AST expressions, a
|
||||||
@@ -276,6 +261,7 @@ class Expression:
|
|||||||
|
|
||||||
# Parser.
|
# Parser.
|
||||||
|
|
||||||
|
|
||||||
class ParseError(Exception):
|
class ParseError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -295,7 +281,7 @@ class Parser:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, string, in_argument=False):
|
def __init__(self, string, in_argument=False):
|
||||||
""" Create a new parser.
|
"""Create a new parser.
|
||||||
:param in_arguments: boolean that indicates the parser is to be
|
:param in_arguments: boolean that indicates the parser is to be
|
||||||
used for parsing function arguments, ie. considering commas
|
used for parsing function arguments, ie. considering commas
|
||||||
(`ARG_SEP`) a special character
|
(`ARG_SEP`) a special character
|
||||||
@@ -306,10 +292,16 @@ class Parser:
|
|||||||
self.parts = []
|
self.parts = []
|
||||||
|
|
||||||
# Common parsing resources.
|
# Common parsing resources.
|
||||||
special_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_OPEN, GROUP_CLOSE,
|
special_chars = (
|
||||||
ESCAPE_CHAR)
|
SYMBOL_DELIM,
|
||||||
special_char_re = re.compile(r'[%s]|\Z' %
|
FUNC_DELIM,
|
||||||
''.join(re.escape(c) for c in special_chars))
|
GROUP_OPEN,
|
||||||
|
GROUP_CLOSE,
|
||||||
|
ESCAPE_CHAR,
|
||||||
|
)
|
||||||
|
special_char_re = re.compile(
|
||||||
|
r"[%s]|\Z" % "".join(re.escape(c) for c in special_chars)
|
||||||
|
)
|
||||||
escapable_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_CLOSE, ARG_SEP)
|
escapable_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_CLOSE, ARG_SEP)
|
||||||
terminator_chars = (GROUP_CLOSE,)
|
terminator_chars = (GROUP_CLOSE,)
|
||||||
|
|
||||||
@@ -326,9 +318,10 @@ class Parser:
|
|||||||
if self.in_argument:
|
if self.in_argument:
|
||||||
extra_special_chars = (ARG_SEP,)
|
extra_special_chars = (ARG_SEP,)
|
||||||
special_char_re = re.compile(
|
special_char_re = re.compile(
|
||||||
r'[%s]|\Z' % ''.join(
|
r"[%s]|\Z"
|
||||||
re.escape(c) for c in
|
% "".join(
|
||||||
self.special_chars + extra_special_chars
|
re.escape(c)
|
||||||
|
for c in self.special_chars + extra_special_chars
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -341,10 +334,10 @@ class Parser:
|
|||||||
# A non-special character. Skip to the next special
|
# A non-special character. Skip to the next special
|
||||||
# character, treating the interstice as literal text.
|
# character, treating the interstice as literal text.
|
||||||
next_pos = (
|
next_pos = (
|
||||||
special_char_re.search(
|
special_char_re.search(self.string[self.pos :]).start()
|
||||||
self.string[self.pos:]).start() + self.pos
|
+ self.pos
|
||||||
)
|
)
|
||||||
text_parts.append(self.string[self.pos:next_pos])
|
text_parts.append(self.string[self.pos : next_pos])
|
||||||
self.pos = next_pos
|
self.pos = next_pos
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -358,8 +351,9 @@ class Parser:
|
|||||||
break
|
break
|
||||||
|
|
||||||
next_char = self.string[self.pos + 1]
|
next_char = self.string[self.pos + 1]
|
||||||
if char == ESCAPE_CHAR and next_char in (self.escapable_chars +
|
if char == ESCAPE_CHAR and next_char in (
|
||||||
extra_special_chars):
|
self.escapable_chars + extra_special_chars
|
||||||
|
):
|
||||||
# An escaped special character ($$, $}, etc.). Note that
|
# An escaped special character ($$, $}, etc.). Note that
|
||||||
# ${ is not an escape sequence: this is ambiguous with
|
# ${ is not an escape sequence: this is ambiguous with
|
||||||
# the start of a symbol and it's not necessary (just
|
# the start of a symbol and it's not necessary (just
|
||||||
@@ -370,7 +364,7 @@ class Parser:
|
|||||||
|
|
||||||
# Shift all characters collected so far into a single string.
|
# Shift all characters collected so far into a single string.
|
||||||
if text_parts:
|
if text_parts:
|
||||||
self.parts.append(''.join(text_parts))
|
self.parts.append("".join(text_parts))
|
||||||
text_parts = []
|
text_parts = []
|
||||||
|
|
||||||
if char == SYMBOL_DELIM:
|
if char == SYMBOL_DELIM:
|
||||||
@@ -392,7 +386,7 @@ class Parser:
|
|||||||
|
|
||||||
# If any parsed characters remain, shift them into a string.
|
# If any parsed characters remain, shift them into a string.
|
||||||
if text_parts:
|
if text_parts:
|
||||||
self.parts.append(''.join(text_parts))
|
self.parts.append("".join(text_parts))
|
||||||
|
|
||||||
def parse_symbol(self):
|
def parse_symbol(self):
|
||||||
"""Parse a variable reference (like ``$foo`` or ``${foo}``)
|
"""Parse a variable reference (like ``$foo`` or ``${foo}``)
|
||||||
@@ -419,21 +413,23 @@ class Parser:
|
|||||||
closer = self.string.find(GROUP_CLOSE, self.pos)
|
closer = self.string.find(GROUP_CLOSE, self.pos)
|
||||||
if closer == -1 or closer == self.pos:
|
if closer == -1 or closer == self.pos:
|
||||||
# No closing brace found or identifier is empty.
|
# No closing brace found or identifier is empty.
|
||||||
self.parts.append(self.string[start_pos:self.pos])
|
self.parts.append(self.string[start_pos : self.pos])
|
||||||
else:
|
else:
|
||||||
# Closer found.
|
# Closer found.
|
||||||
ident = self.string[self.pos:closer]
|
ident = self.string[self.pos : closer]
|
||||||
self.pos = closer + 1
|
self.pos = closer + 1
|
||||||
self.parts.append(Symbol(ident,
|
self.parts.append(
|
||||||
self.string[start_pos:self.pos]))
|
Symbol(ident, self.string[start_pos : self.pos])
|
||||||
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# A bare-word symbol.
|
# A bare-word symbol.
|
||||||
ident = self._parse_ident()
|
ident = self._parse_ident()
|
||||||
if ident:
|
if ident:
|
||||||
# Found a real symbol.
|
# Found a real symbol.
|
||||||
self.parts.append(Symbol(ident,
|
self.parts.append(
|
||||||
self.string[start_pos:self.pos]))
|
Symbol(ident, self.string[start_pos : self.pos])
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# A standalone $.
|
# A standalone $.
|
||||||
self.parts.append(SYMBOL_DELIM)
|
self.parts.append(SYMBOL_DELIM)
|
||||||
@@ -457,25 +453,24 @@ class Parser:
|
|||||||
|
|
||||||
if self.pos >= len(self.string):
|
if self.pos >= len(self.string):
|
||||||
# Identifier terminates string.
|
# Identifier terminates string.
|
||||||
self.parts.append(self.string[start_pos:self.pos])
|
self.parts.append(self.string[start_pos : self.pos])
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.string[self.pos] != GROUP_OPEN:
|
if self.string[self.pos] != GROUP_OPEN:
|
||||||
# Argument list not opened.
|
# Argument list not opened.
|
||||||
self.parts.append(self.string[start_pos:self.pos])
|
self.parts.append(self.string[start_pos : self.pos])
|
||||||
return
|
return
|
||||||
|
|
||||||
# Skip past opening brace and try to parse an argument list.
|
# Skip past opening brace and try to parse an argument list.
|
||||||
self.pos += 1
|
self.pos += 1
|
||||||
args = self.parse_argument_list()
|
args = self.parse_argument_list()
|
||||||
if self.pos >= len(self.string) or \
|
if self.pos >= len(self.string) or self.string[self.pos] != GROUP_CLOSE:
|
||||||
self.string[self.pos] != GROUP_CLOSE:
|
|
||||||
# Arguments unclosed.
|
# Arguments unclosed.
|
||||||
self.parts.append(self.string[start_pos:self.pos])
|
self.parts.append(self.string[start_pos : self.pos])
|
||||||
return
|
return
|
||||||
|
|
||||||
self.pos += 1 # Move past closing brace.
|
self.pos += 1 # Move past closing brace.
|
||||||
self.parts.append(Call(ident, args, self.string[start_pos:self.pos]))
|
self.parts.append(Call(ident, args, self.string[start_pos : self.pos]))
|
||||||
|
|
||||||
def parse_argument_list(self):
|
def parse_argument_list(self):
|
||||||
"""Parse a list of arguments starting at ``pos``, returning a
|
"""Parse a list of arguments starting at ``pos``, returning a
|
||||||
@@ -487,15 +482,17 @@ class Parser:
|
|||||||
expressions = []
|
expressions = []
|
||||||
|
|
||||||
while self.pos < len(self.string):
|
while self.pos < len(self.string):
|
||||||
subparser = Parser(self.string[self.pos:], in_argument=True)
|
subparser = Parser(self.string[self.pos :], in_argument=True)
|
||||||
subparser.parse_expression()
|
subparser.parse_expression()
|
||||||
|
|
||||||
# Extract and advance past the parsed expression.
|
# Extract and advance past the parsed expression.
|
||||||
expressions.append(Expression(subparser.parts))
|
expressions.append(Expression(subparser.parts))
|
||||||
self.pos += subparser.pos
|
self.pos += subparser.pos
|
||||||
|
|
||||||
if self.pos >= len(self.string) or \
|
if (
|
||||||
self.string[self.pos] == GROUP_CLOSE:
|
self.pos >= len(self.string)
|
||||||
|
or self.string[self.pos] == GROUP_CLOSE
|
||||||
|
):
|
||||||
# Argument list terminated by EOF or closing brace.
|
# Argument list terminated by EOF or closing brace.
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -510,8 +507,8 @@ class Parser:
|
|||||||
"""Parse an identifier and return it (possibly an empty string).
|
"""Parse an identifier and return it (possibly an empty string).
|
||||||
Updates ``pos``.
|
Updates ``pos``.
|
||||||
"""
|
"""
|
||||||
remainder = self.string[self.pos:]
|
remainder = self.string[self.pos :]
|
||||||
ident = re.match(r'\w*', remainder).group(0)
|
ident = re.match(r"\w*", remainder).group(0)
|
||||||
self.pos += len(ident)
|
self.pos += len(ident)
|
||||||
return ident
|
return ident
|
||||||
|
|
||||||
@@ -524,32 +521,20 @@ def _parse(template):
|
|||||||
parser.parse_expression()
|
parser.parse_expression()
|
||||||
|
|
||||||
parts = parser.parts
|
parts = parser.parts
|
||||||
remainder = parser.string[parser.pos:]
|
remainder = parser.string[parser.pos :]
|
||||||
if remainder:
|
if remainder:
|
||||||
parts.append(remainder)
|
parts.append(remainder)
|
||||||
return Expression(parts)
|
return Expression(parts)
|
||||||
|
|
||||||
|
|
||||||
def cached(func):
|
@functools.lru_cache(maxsize=128)
|
||||||
"""Like the `functools.lru_cache` decorator, but works (as a no-op)
|
|
||||||
on Python < 3.2.
|
|
||||||
"""
|
|
||||||
if hasattr(functools, 'lru_cache'):
|
|
||||||
return functools.lru_cache(maxsize=128)(func)
|
|
||||||
else:
|
|
||||||
# Do nothing when lru_cache is not available.
|
|
||||||
return func
|
|
||||||
|
|
||||||
|
|
||||||
@cached
|
|
||||||
def template(fmt):
|
def template(fmt):
|
||||||
return Template(fmt)
|
return Template(fmt)
|
||||||
|
|
||||||
|
|
||||||
# External interface.
|
# External interface.
|
||||||
class Template:
|
class Template:
|
||||||
"""A string template, including text, Symbols, and Calls.
|
"""A string template, including text, Symbols, and Calls."""
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, template):
|
def __init__(self, template):
|
||||||
self.expr = _parse(template)
|
self.expr = _parse(template)
|
||||||
@@ -568,8 +553,7 @@ class Template:
|
|||||||
return self.expr.evaluate(Environment(values, functions))
|
return self.expr.evaluate(Environment(values, functions))
|
||||||
|
|
||||||
def substitute(self, values={}, functions={}):
|
def substitute(self, values={}, functions={}):
|
||||||
"""Evaluate the template given the values and functions.
|
"""Evaluate the template given the values and functions."""
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
res = self.compiled(values, functions)
|
res = self.compiled(values, functions)
|
||||||
except Exception: # Handle any exceptions thrown by compiled version.
|
except Exception: # Handle any exceptions thrown by compiled version.
|
||||||
@@ -599,24 +583,29 @@ class Template:
|
|||||||
for funcname in funcnames:
|
for funcname in funcnames:
|
||||||
args[FUNCTION_PREFIX + funcname] = functions[funcname]
|
args[FUNCTION_PREFIX + funcname] = functions[funcname]
|
||||||
parts = func(**args)
|
parts = func(**args)
|
||||||
return ''.join(parts)
|
return "".join(parts)
|
||||||
|
|
||||||
return wrapper_func
|
return wrapper_func
|
||||||
|
|
||||||
|
|
||||||
# Performance tests.
|
# Performance tests.
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
import timeit
|
import timeit
|
||||||
_tmpl = Template('foo $bar %baz{foozle $bar barzle} $bar')
|
|
||||||
_vars = {'bar': 'qux'}
|
_tmpl = Template("foo $bar %baz{foozle $bar barzle} $bar")
|
||||||
_funcs = {'baz': str.upper}
|
_vars = {"bar": "qux"}
|
||||||
interp_time = timeit.timeit('_tmpl.interpret(_vars, _funcs)',
|
_funcs = {"baz": str.upper}
|
||||||
'from __main__ import _tmpl, _vars, _funcs',
|
interp_time = timeit.timeit(
|
||||||
number=10000)
|
"_tmpl.interpret(_vars, _funcs)",
|
||||||
|
"from __main__ import _tmpl, _vars, _funcs",
|
||||||
|
number=10000,
|
||||||
|
)
|
||||||
print(interp_time)
|
print(interp_time)
|
||||||
comp_time = timeit.timeit('_tmpl.substitute(_vars, _funcs)',
|
comp_time = timeit.timeit(
|
||||||
'from __main__ import _tmpl, _vars, _funcs',
|
"_tmpl.substitute(_vars, _funcs)",
|
||||||
number=10000)
|
"from __main__ import _tmpl, _vars, _funcs",
|
||||||
|
number=10000,
|
||||||
|
)
|
||||||
print(comp_time)
|
print(comp_time)
|
||||||
print('Speedup:', interp_time / comp_time)
|
print("Speedup:", interp_time / comp_time)
|
||||||
|
|||||||
+30
-51
@@ -1,5 +1,6 @@
|
|||||||
# This file is part of beets.
|
# This file is part of beets.
|
||||||
# Copyright 2016, Adrian Sampson.
|
# Copyright 2016, Adrian Sampson.
|
||||||
|
# Copyright 2024, Arav K.
|
||||||
#
|
#
|
||||||
# Permission is hereby granted, free of charge, to any person obtaining
|
# Permission is hereby granted, free of charge, to any person obtaining
|
||||||
# a copy of this software and associated documentation files (the
|
# a copy of this software and associated documentation files (the
|
||||||
@@ -14,71 +15,49 @@
|
|||||||
|
|
||||||
"""Simple library to work out if a file is hidden on different platforms."""
|
"""Simple library to work out if a file is hidden on different platforms."""
|
||||||
|
|
||||||
|
import ctypes
|
||||||
import os
|
import os
|
||||||
import stat
|
import stat
|
||||||
import ctypes
|
|
||||||
import sys
|
import sys
|
||||||
import beets.util
|
from pathlib import Path
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
|
||||||
def _is_hidden_osx(path):
|
def is_hidden(path: Union[bytes, Path]) -> bool:
|
||||||
"""Return whether or not a file is hidden on OS X.
|
|
||||||
|
|
||||||
This uses os.lstat to work out if a file has the "hidden" flag.
|
|
||||||
"""
|
"""
|
||||||
file_stat = os.lstat(beets.util.syspath(path))
|
Determine whether the given path is treated as a 'hidden file' by the OS.
|
||||||
|
|
||||||
if hasattr(file_stat, 'st_flags') and hasattr(stat, 'UF_HIDDEN'):
|
|
||||||
return bool(file_stat.st_flags & stat.UF_HIDDEN)
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _is_hidden_win(path):
|
|
||||||
"""Return whether or not a file is hidden on Windows.
|
|
||||||
|
|
||||||
This uses GetFileAttributes to work out if a file has the "hidden" flag
|
|
||||||
(FILE_ATTRIBUTE_HIDDEN).
|
|
||||||
"""
|
"""
|
||||||
# FILE_ATTRIBUTE_HIDDEN = 2 (0x2) from GetFileAttributes documentation.
|
|
||||||
hidden_mask = 2
|
|
||||||
|
|
||||||
# Retrieve the attributes for the file.
|
if isinstance(path, bytes):
|
||||||
attrs = ctypes.windll.kernel32.GetFileAttributesW(beets.util.syspath(path))
|
path = Path(os.fsdecode(path))
|
||||||
|
|
||||||
# Ensure we have valid attribues and compare them against the mask.
|
# TODO: Avoid doing a platform check on every invocation of the function.
|
||||||
return attrs >= 0 and attrs & hidden_mask
|
# TODO: Stop supporting 'bytes' inputs once 'pathlib' is fully integrated.
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# On Windows, we check for an FS-provided attribute.
|
||||||
|
|
||||||
def _is_hidden_dot(path):
|
# FILE_ATTRIBUTE_HIDDEN = 2 (0x2) from GetFileAttributes documentation.
|
||||||
"""Return whether or not a file starts with a dot.
|
hidden_mask = 2
|
||||||
|
|
||||||
Files starting with a dot are seen as "hidden" files on Unix-based OSes.
|
# Retrieve the attributes for the file.
|
||||||
"""
|
attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path))
|
||||||
return os.path.basename(path).startswith(b'.')
|
|
||||||
|
|
||||||
|
# Ensure the attribute mask is valid.
|
||||||
|
if attrs < 0:
|
||||||
|
return False
|
||||||
|
|
||||||
def is_hidden(path):
|
# Check for the hidden attribute.
|
||||||
"""Return whether or not a file is hidden. `path` should be a
|
return attrs & hidden_mask
|
||||||
bytestring filename.
|
|
||||||
|
|
||||||
This method works differently depending on the platform it is called on.
|
# On OS X, we check for an FS-provided attribute.
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
if hasattr(os.stat_result, "st_flags") and hasattr(stat, "UF_HIDDEN"):
|
||||||
|
if path.lstat().st_flags & stat.UF_HIDDEN:
|
||||||
|
return True
|
||||||
|
|
||||||
On OS X, it uses both the result of `is_hidden_osx` and `is_hidden_dot` to
|
# On all non-Windows platforms, we check for a '.'-prefixed file name.
|
||||||
work out if a file is hidden.
|
if path.name.startswith("."):
|
||||||
|
return True
|
||||||
|
|
||||||
On Windows, it uses the result of `is_hidden_win` to work out if a file is
|
return False
|
||||||
hidden.
|
|
||||||
|
|
||||||
On any other operating systems (i.e. Linux), it uses `is_hidden_dot` to
|
|
||||||
work out if a file is hidden.
|
|
||||||
"""
|
|
||||||
# Run platform specific functions depending on the platform
|
|
||||||
if sys.platform == 'darwin':
|
|
||||||
return _is_hidden_osx(path) or _is_hidden_dot(path)
|
|
||||||
elif sys.platform == 'win32':
|
|
||||||
return _is_hidden_win(path)
|
|
||||||
else:
|
|
||||||
return _is_hidden_dot(path)
|
|
||||||
|
|
||||||
__all__ = ['is_hidden']
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# This file is part of beets.
|
||||||
|
# Copyright 2016, Adrian Sampson.
|
||||||
|
#
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
# a copy of this software and associated documentation files (the
|
||||||
|
# "Software"), to deal in the Software without restriction, including
|
||||||
|
# without limitation the rights to use, copy, modify, merge, publish,
|
||||||
|
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||||
|
# permit persons to whom the Software is furnished to do so, subject to
|
||||||
|
# the following conditions:
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be
|
||||||
|
# included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
"""Helpers around the extraction of album/track ID's from metadata sources."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Spotify IDs consist of 22 alphanumeric characters
|
||||||
|
# (zero-left-padded base62 representation of randomly generated UUID4)
|
||||||
|
spotify_id_regex = {
|
||||||
|
"pattern": r"(^|open\.spotify\.com/{}/)([0-9A-Za-z]{{22}})",
|
||||||
|
"match_group": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
deezer_id_regex = {
|
||||||
|
"pattern": r"(^|deezer\.com/)([a-z]*/)?({}/)?(\d+)",
|
||||||
|
"match_group": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
beatport_id_regex = {
|
||||||
|
"pattern": r"(^|beatport\.com/release/.+/)(\d+)$",
|
||||||
|
"match_group": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
# A note on Bandcamp: There is no such thing as a Bandcamp album or artist ID,
|
||||||
|
# the URL can be used as the identifier. The Bandcamp metadata source plugin
|
||||||
|
# works that way - https://github.com/snejus/beetcamp. Bandcamp album
|
||||||
|
# URLs usually look like: https://nameofartist.bandcamp.com/album/nameofalbum
|
||||||
|
|
||||||
|
|
||||||
|
def extract_discogs_id_regex(album_id):
|
||||||
|
"""Returns the Discogs_id or None."""
|
||||||
|
# Discogs-IDs are simple integers. In order to avoid confusion with
|
||||||
|
# other metadata plugins, we only look for very specific formats of the
|
||||||
|
# input string:
|
||||||
|
# - plain integer, optionally wrapped in brackets and prefixed by an
|
||||||
|
# 'r', as this is how discogs displays the release ID on its webpage.
|
||||||
|
# - legacy url format: discogs.com/<name of release>/release/<id>
|
||||||
|
# - legacy url short format: discogs.com/release/<id>
|
||||||
|
# - current url format: discogs.com/release/<id>-<name of release>
|
||||||
|
# See #291, #4080 and #4085 for the discussions leading up to these
|
||||||
|
# patterns.
|
||||||
|
# Regex has been tested here https://regex101.com/r/TOu7kw/1
|
||||||
|
|
||||||
|
for pattern in [
|
||||||
|
r"^\[?r?(?P<id>\d+)\]?$",
|
||||||
|
r"discogs\.com/release/(?P<id>\d+)-?",
|
||||||
|
r"discogs\.com/[^/]+/release/(?P<id>\d+)",
|
||||||
|
]:
|
||||||
|
match = re.search(pattern, album_id)
|
||||||
|
if match:
|
||||||
|
return int(match.group("id"))
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# This file is part of beets.
|
||||||
|
# Copyright 2022, J0J0 Todos.
|
||||||
|
#
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
# a copy of this software and associated documentation files (the
|
||||||
|
# "Software"), to deal in the Software without restriction, including
|
||||||
|
# without limitation the rights to use, copy, modify, merge, publish,
|
||||||
|
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||||
|
# permit persons to whom the Software is furnished to do so, subject to
|
||||||
|
# the following conditions:
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be
|
||||||
|
# included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
"""Provides utilities to read, write and manipulate m3u playlist files."""
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from beets.util import FilesystemError, mkdirall, normpath, syspath
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyPlaylistError(Exception):
|
||||||
|
"""Raised when a playlist file without media files is saved or loaded."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class M3UFile:
|
||||||
|
"""Reads and writes m3u or m3u8 playlist files."""
|
||||||
|
|
||||||
|
def __init__(self, path):
|
||||||
|
"""``path`` is the absolute path to the playlist file.
|
||||||
|
|
||||||
|
The playlist file type, m3u or m3u8 is determined by 1) the ending
|
||||||
|
being m3u8 and 2) the file paths contained in the list being utf-8
|
||||||
|
encoded. Since the list is passed from the outside, this is currently
|
||||||
|
out of control of this class.
|
||||||
|
"""
|
||||||
|
self.path = path
|
||||||
|
self.extm3u = False
|
||||||
|
self.media_list = []
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
"""Reads the m3u file from disk and sets the object's attributes."""
|
||||||
|
pl_normpath = normpath(self.path)
|
||||||
|
try:
|
||||||
|
with open(syspath(pl_normpath), "rb") as pl_file:
|
||||||
|
raw_contents = pl_file.readlines()
|
||||||
|
except OSError as exc:
|
||||||
|
raise FilesystemError(
|
||||||
|
exc, "read", (pl_normpath,), traceback.format_exc()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.extm3u = True if raw_contents[0].rstrip() == b"#EXTM3U" else False
|
||||||
|
for line in raw_contents[1:]:
|
||||||
|
if line.startswith(b"#"):
|
||||||
|
# Support for specific EXTM3U comments could be added here.
|
||||||
|
continue
|
||||||
|
self.media_list.append(normpath(line.rstrip()))
|
||||||
|
if not self.media_list:
|
||||||
|
raise EmptyPlaylistError
|
||||||
|
|
||||||
|
def set_contents(self, media_list, extm3u=True):
|
||||||
|
"""Sets self.media_list to a list of media file paths.
|
||||||
|
|
||||||
|
Also sets additional flags, changing the final m3u-file's format.
|
||||||
|
|
||||||
|
``media_list`` is a list of paths to media files that should be added
|
||||||
|
to the playlist (relative or absolute paths, that's the responsibility
|
||||||
|
of the caller). By default the ``extm3u`` flag is set, to ensure a
|
||||||
|
save-operation writes an m3u-extended playlist (comment "#EXTM3U" at
|
||||||
|
the top of the file).
|
||||||
|
"""
|
||||||
|
self.media_list = media_list
|
||||||
|
self.extm3u = extm3u
|
||||||
|
|
||||||
|
def write(self):
|
||||||
|
"""Writes the m3u file to disk.
|
||||||
|
|
||||||
|
Handles the creation of potential parent directories.
|
||||||
|
"""
|
||||||
|
header = [b"#EXTM3U"] if self.extm3u else []
|
||||||
|
if not self.media_list:
|
||||||
|
raise EmptyPlaylistError
|
||||||
|
contents = header + self.media_list
|
||||||
|
pl_normpath = normpath(self.path)
|
||||||
|
mkdirall(pl_normpath)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(syspath(pl_normpath), "wb") as pl_file:
|
||||||
|
for line in contents:
|
||||||
|
pl_file.write(line + b"\n")
|
||||||
|
pl_file.write(b"\n") # Final linefeed to prevent noeol file.
|
||||||
|
except OSError as exc:
|
||||||
|
raise FilesystemError(
|
||||||
|
exc, "create", (pl_normpath,), traceback.format_exc()
|
||||||
|
)
|
||||||
+29
-28
@@ -33,11 +33,11 @@ in place of any single coroutine.
|
|||||||
|
|
||||||
|
|
||||||
import queue
|
import queue
|
||||||
from threading import Thread, Lock
|
|
||||||
import sys
|
import sys
|
||||||
|
from threading import Lock, Thread
|
||||||
|
|
||||||
BUBBLE = '__PIPELINE_BUBBLE__'
|
BUBBLE = "__PIPELINE_BUBBLE__"
|
||||||
POISON = '__PIPELINE_POISON__'
|
POISON = "__PIPELINE_POISON__"
|
||||||
|
|
||||||
DEFAULT_QUEUE_SIZE = 16
|
DEFAULT_QUEUE_SIZE = 16
|
||||||
|
|
||||||
@@ -48,6 +48,7 @@ def _invalidate_queue(q, val=None, sync=True):
|
|||||||
which defaults to None. `sync` controls whether a lock is
|
which defaults to None. `sync` controls whether a lock is
|
||||||
required (because it's not reentrant!).
|
required (because it's not reentrant!).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _qsize(len=len):
|
def _qsize(len=len):
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
@@ -75,8 +76,8 @@ def _invalidate_queue(q, val=None, sync=True):
|
|||||||
q._qsize = _qsize
|
q._qsize = _qsize
|
||||||
q._put = _put
|
q._put = _put
|
||||||
q._get = _get
|
q._get = _get
|
||||||
q.not_empty.notifyAll()
|
q.not_empty.notify_all()
|
||||||
q.not_full.notifyAll()
|
q.not_full.notify_all()
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
if sync:
|
if sync:
|
||||||
@@ -168,6 +169,7 @@ def stage(func):
|
|||||||
while True:
|
while True:
|
||||||
task = yield task
|
task = yield task
|
||||||
task = func(*(args + (task,)))
|
task = func(*(args + (task,)))
|
||||||
|
|
||||||
return coro
|
return coro
|
||||||
|
|
||||||
|
|
||||||
@@ -191,6 +193,7 @@ def mutator_stage(func):
|
|||||||
while True:
|
while True:
|
||||||
task = yield task
|
task = yield task
|
||||||
func(*(args + (task,)))
|
func(*(args + (task,)))
|
||||||
|
|
||||||
return coro
|
return coro
|
||||||
|
|
||||||
|
|
||||||
@@ -218,20 +221,18 @@ class PipelineThread(Thread):
|
|||||||
self.exc_info = None
|
self.exc_info = None
|
||||||
|
|
||||||
def abort(self):
|
def abort(self):
|
||||||
"""Shut down the thread at the next chance possible.
|
"""Shut down the thread at the next chance possible."""
|
||||||
"""
|
|
||||||
with self.abort_lock:
|
with self.abort_lock:
|
||||||
self.abort_flag = True
|
self.abort_flag = True
|
||||||
|
|
||||||
# Ensure that we are not blocking on a queue read or write.
|
# Ensure that we are not blocking on a queue read or write.
|
||||||
if hasattr(self, 'in_queue'):
|
if hasattr(self, "in_queue"):
|
||||||
_invalidate_queue(self.in_queue, POISON)
|
_invalidate_queue(self.in_queue, POISON)
|
||||||
if hasattr(self, 'out_queue'):
|
if hasattr(self, "out_queue"):
|
||||||
_invalidate_queue(self.out_queue, POISON)
|
_invalidate_queue(self.out_queue, POISON)
|
||||||
|
|
||||||
def abort_all(self, exc_info):
|
def abort_all(self, exc_info):
|
||||||
"""Abort all other threads in the system for an exception.
|
"""Abort all other threads in the system for an exception."""
|
||||||
"""
|
|
||||||
self.exc_info = exc_info
|
self.exc_info = exc_info
|
||||||
for thread in self.all_threads:
|
for thread in self.all_threads:
|
||||||
thread.abort()
|
thread.abort()
|
||||||
@@ -373,7 +374,7 @@ class Pipeline:
|
|||||||
be at least two stages.
|
be at least two stages.
|
||||||
"""
|
"""
|
||||||
if len(stages) < 2:
|
if len(stages) < 2:
|
||||||
raise ValueError('pipeline must have at least two stages')
|
raise ValueError("pipeline must have at least two stages")
|
||||||
self.stages = []
|
self.stages = []
|
||||||
for stage in stages:
|
for stage in stages:
|
||||||
if isinstance(stage, (list, tuple)):
|
if isinstance(stage, (list, tuple)):
|
||||||
@@ -405,15 +406,15 @@ class Pipeline:
|
|||||||
# Middle stages.
|
# Middle stages.
|
||||||
for i in range(1, queue_count):
|
for i in range(1, queue_count):
|
||||||
for coro in self.stages[i]:
|
for coro in self.stages[i]:
|
||||||
threads.append(MiddlePipelineThread(
|
threads.append(
|
||||||
coro, queues[i - 1], queues[i], threads
|
MiddlePipelineThread(
|
||||||
))
|
coro, queues[i - 1], queues[i], threads
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Last stage.
|
# Last stage.
|
||||||
for coro in self.stages[-1]:
|
for coro in self.stages[-1]:
|
||||||
threads.append(
|
threads.append(LastPipelineThread(coro, queues[-1], threads))
|
||||||
LastPipelineThread(coro, queues[-1], threads)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Start threads.
|
# Start threads.
|
||||||
for thread in threads:
|
for thread in threads:
|
||||||
@@ -472,21 +473,21 @@ class Pipeline:
|
|||||||
|
|
||||||
|
|
||||||
# Smoke test.
|
# Smoke test.
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
import time
|
import time
|
||||||
|
|
||||||
# Test a normally-terminating pipeline both in sequence and
|
# Test a normally-terminating pipeline both in sequence and
|
||||||
# in parallel.
|
# in parallel.
|
||||||
def produce():
|
def produce():
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
print('generating %i' % i)
|
print("generating %i" % i)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
yield i
|
yield i
|
||||||
|
|
||||||
def work():
|
def work():
|
||||||
num = yield
|
num = yield
|
||||||
while True:
|
while True:
|
||||||
print('processing %i' % num)
|
print("processing %i" % num)
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
num = yield num * 2
|
num = yield num * 2
|
||||||
|
|
||||||
@@ -494,7 +495,7 @@ if __name__ == '__main__':
|
|||||||
while True:
|
while True:
|
||||||
num = yield
|
num = yield
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
print('received %i' % num)
|
print("received %i" % num)
|
||||||
|
|
||||||
ts_start = time.time()
|
ts_start = time.time()
|
||||||
Pipeline([produce(), work(), consume()]).run_sequential()
|
Pipeline([produce(), work(), consume()]).run_sequential()
|
||||||
@@ -503,22 +504,22 @@ if __name__ == '__main__':
|
|||||||
ts_par = time.time()
|
ts_par = time.time()
|
||||||
Pipeline([produce(), (work(), work()), consume()]).run_parallel()
|
Pipeline([produce(), (work(), work()), consume()]).run_parallel()
|
||||||
ts_end = time.time()
|
ts_end = time.time()
|
||||||
print('Sequential time:', ts_seq - ts_start)
|
print("Sequential time:", ts_seq - ts_start)
|
||||||
print('Parallel time:', ts_par - ts_seq)
|
print("Parallel time:", ts_par - ts_seq)
|
||||||
print('Multiply-parallel time:', ts_end - ts_par)
|
print("Multiply-parallel time:", ts_end - ts_par)
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Test a pipeline that raises an exception.
|
# Test a pipeline that raises an exception.
|
||||||
def exc_produce():
|
def exc_produce():
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
print('generating %i' % i)
|
print("generating %i" % i)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
yield i
|
yield i
|
||||||
|
|
||||||
def exc_work():
|
def exc_work():
|
||||||
num = yield
|
num = yield
|
||||||
while True:
|
while True:
|
||||||
print('processing %i' % num)
|
print("processing %i" % num)
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
if num == 3:
|
if num == 3:
|
||||||
raise Exception()
|
raise Exception()
|
||||||
@@ -527,6 +528,6 @@ if __name__ == '__main__':
|
|||||||
def exc_consume():
|
def exc_consume():
|
||||||
while True:
|
while True:
|
||||||
num = yield
|
num = yield
|
||||||
print('received %i' % num)
|
print("received %i" % num)
|
||||||
|
|
||||||
Pipeline([exc_produce(), exc_work(), exc_consume()]).run_parallel(1)
|
Pipeline([exc_produce(), exc_work(), exc_consume()]).run_parallel(1)
|
||||||
|
|||||||
+2
-1
@@ -17,9 +17,10 @@ libraries.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
|
||||||
from beets import util
|
from beets import util
|
||||||
|
|
||||||
Node = namedtuple('Node', ['files', 'dirs'])
|
Node = namedtuple("Node", ["files", "dirs"])
|
||||||
|
|
||||||
|
|
||||||
def _insert(node, path, itemid):
|
def _insert(node, path, itemid):
|
||||||
|
|||||||
@@ -17,4 +17,5 @@
|
|||||||
|
|
||||||
# Make this a namespace package.
|
# Make this a namespace package.
|
||||||
from pkgutil import extend_path
|
from pkgutil import extend_path
|
||||||
|
|
||||||
__path__ = extend_path(__path__, __name__)
|
__path__ = extend_path(__path__, __name__)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user