-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathLibraryMonitor.cs
285 lines (256 loc) · 11.1 KB
/
LibraryMonitor.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using fastmusic.DataProviders;
using fastmusic.DataTypes;
namespace fastmusic
{
// TODO This class is a great candidate for unit tests
/// <summary>
/// Monitors the user-configured library directories at a user-configured interval,
/// scanning them for new, updated, or deleted files.
/// </summary>
public class LibraryMonitor
{
/// <summary>
/// Private instance used for singleton pattern
/// </summary>
private static LibraryMonitor m_instance;
/// <summary>
/// List of full paths to all music library locations on disk
/// </summary>
private List<string> m_libraryLocations;
/// <summary>
/// List of wildcard patterns that will be used to monitor
/// files of certain extensions in the library
/// </summary>
private List<string> m_filePatterns = new List<string>();
/// <summary>
/// List of full paths to music files that have changed on disk
/// since the last database sync.
/// </summary>
private HashSet<string> m_filesToUpdate = new HashSet<string>();
/// <summary>
/// List of full paths to music files that have been created since
/// the last database sync.
/// </summary>
private List<string> m_filesToAdd = new List<string>();
/// <summary>
/// Enables the library monitor to sync the database with the library periodically.
/// </summary>
private Timer m_syncTimer;
/// <summary>
/// Interval in seconds between the end of the last database sync
/// and the starty of the next one.
/// </summary>
private const int SYNC_INTERVAL_SECONDS = 120; // TODO Make this user configurable
/// <summary>
/// Batch size for adding, removing, or updating records.
/// </summary>
private const int SAVE_TO_DISK_INTERVAL = 2048;
/// <summary>
/// Singleton constructor. LibraryMonitor will be created if it does not already exist.
/// Once created, the instance lives until the program is terminated.
/// </summary>
/// <param name="libraryLocations">List of full paths to all directories to monitor for music</param>
/// <param name="fileTypes">List of music file extensions to watch in @param libraryLocations</param>
/// <returns>An instance of the library monitor</returns>
public static LibraryMonitor GetInstance(List<String> libraryLocations, List<String> fileTypes)
{
if(m_instance == null) m_instance = new LibraryMonitor(libraryLocations, fileTypes);
return m_instance;
}
/// <summary>
/// Sets up a routine that monitors all files of type @param fileTypes
/// in all directories in @param libraryLocations.
/// The routine will run immediately upon construction, then at a specified interval,
/// always in a separate thread.
/// </summary>
/// <param name="libraryLocations">List of full paths to all directories to monitor for music</param>
/// <param name="fileTypes">List of music file extensions to watch in @param libraryLocations</param>
private LibraryMonitor(List<String> libraryLocations, List<String> fileTypes)
{
m_libraryLocations = libraryLocations;
m_filePatterns = fileTypes.Select( fileType =>
$"*.{fileType}"
).ToList();
// Schedule a task to synchronise filesystem and DB every so often
// We use Timeout.Infinite to avoid multiple syncs running concurrently
// This is changed at the end of SynchroniseDb
m_syncTimer = new Timer(async (o) => await SynchroniseDb(), null, 0, Timeout.Infinite);
}
/// <summary>
/// Checks the configured library locations for new/updated/deleted files,
/// and updates the database accordingly
/// </summary>
private async Task SynchroniseDb()
{
await Console.Out.WriteLineAsync("LibraryMonitor: Starting update (enumerating files).");
using(var mp = new MusicProvider())
{
var lastDBUpdateTime = mp.GetLastUpdateTime();
// Set the last update time now
// Otherwise, files that change between now and update completion
// might not get flagged for update up in the next sync round
await mp.SetLastUpdateTime(DateTime.UtcNow.ToUniversalTime());
foreach(var libraryLocation in m_libraryLocations)
{
await FindFilesToUpdate(libraryLocation, mp, lastDBUpdateTime);
}
}
await Console.Out.WriteLineAsync($"LibraryMonitor: {m_filesToUpdate.Count} files need to be updated and {m_filesToAdd.Count} files need to be added.");
await UpdateFiles();
m_filesToUpdate.Clear();
await AddNewFiles();
m_filesToAdd.Clear();
await DeleteStaleDbEntries();
await Console.Out.WriteLineAsync("LibraryMonitor: Database update completed successfully.");
// Schedule the next sync
m_syncTimer.Change(SYNC_INTERVAL_SECONDS * 1000, Timeout.Infinite);
}
/// <summary>
/// Recursively adds all files in @param startDirectory (of the configured file types)
/// that have been created or modified since @lastDBUpdateTime
/// to the list of files that need to be updated in/added to the database
/// </summary>
/// <param name="startDirectory">Where to start looking for new/updated files</param>
/// <param name="mp">Handle to the database</param>
/// <param name="lastDBUpdateTime">Write time beyond which files will be condsidered new or modified</param>
/// <returns></returns>
private async Task FindFilesToUpdate(
string startDirectory,
MusicProvider mp,
DateTime lastDBUpdateTime
)
{
foreach(var subDir in Directory.EnumerateDirectories(startDirectory))
{
if(new DirectoryInfo(subDir).LastWriteTime > lastDBUpdateTime)
{
await FindFilesToUpdate(subDir, mp, lastDBUpdateTime);
}
}
foreach(var filePattern in m_filePatterns)
{
foreach(var file in Directory.EnumerateFiles(startDirectory, filePattern, SearchOption.TopDirectoryOnly))
{
if(!(await mp.AllTracks.AsNoTracking().AnyAsync( t => t.FileName == file )))
{
m_filesToAdd.Add(file);
}
else if(new FileInfo(file).LastWriteTime.ToUniversalTime() > lastDBUpdateTime)
{
m_filesToUpdate.Add(file);
}
}
}
}
/// <summary>
/// Intermediate data structure holding the db representation of a track file
/// and the filesystem representaiton of the track file
/// Used by UpdateFiles
/// </summary>
private class TrackToUpdate {
/// <summary>
/// Fresh track metadata, as loaded from disk
/// </summary>
public TagLib.Tag NewData;
/// <summary>
/// Possibly stale database represenation of the track metadata
/// </summary>
public DbTrack DbRepresentation;
}
/// <summary>
/// Synchronises all database rows selected for update with the file on disk
/// Clears db rows selected for update
/// </summary>
private async Task UpdateFiles()
{
if(m_filesToUpdate.Count() < 1)
{
return;
}
IQueryable<TrackToUpdate> tracksToUpdate;
using(var mp = new MusicProvider())
{
// Load the tags of all files that have been changed recently,
// *then* do the work in the database
// This avoids seeking HDDs back and forth between library and db
tracksToUpdate = mp.AllTracks.Where( t =>
m_filesToUpdate.Contains(t.FileName)
).Select( t =>
new TrackToUpdate{
NewData = TagLib.File.Create(t.FileName).Tag,
DbRepresentation = t
}
).Where( t =>
!t.DbRepresentation.HasSameData(t.NewData)
);
}
// TODO New context for each slice
foreach(var slice in tracksToUpdate.Select(t => t.DbRepresentation).GetSlices(SAVE_TO_DISK_INTERVAL))
{
using(var mp = new MusicProvider())
{
mp.AllTracks.UpdateRange(slice);
await mp.SaveChangesAsync();
}
}
}
/// <summary>
/// Adds all new files marked for addition to the database
/// Clears files marked for addition
/// </summary>
private async Task AddNewFiles()
{
if(m_filesToAdd.Count() < 1)
{
return;
}
foreach(var slice in m_filesToAdd.GetSlices(SAVE_TO_DISK_INTERVAL))
{
var newTracks = new List<DbTrack>();
foreach(var trackFileName in slice)
{
var newTrack = new DbTrack{
FileName = trackFileName
};
newTrack.SetTrackData(TagLib.File.Create(trackFileName).Tag);
newTracks.Add(newTrack);
}
using(MusicProvider mp = new MusicProvider())
{
await mp.AllTracks.AddRangeAsync(newTracks);
await mp.SaveChangesAsync();
}
}
}
/// <summary>
/// Removes all files from the database which no longer exist on disk
/// </summary>
private async Task DeleteStaleDbEntries()
{
using(var mp = new MusicProvider())
{
IQueryable<DbTrack> tracksToDelete = mp.AllTracks.Where( t =>
!File.Exists(t.FileName)
);
await Console.Out.WriteLineAsync($"LibraryMonitor: {tracksToDelete.Count()} tracks need to be removed from the database.");
var i = 0;
foreach(var track in tracksToDelete)
{
mp.AllTracks.Remove(track);
if(i++ % SAVE_TO_DISK_INTERVAL == 0)
{
await mp.SaveChangesAsync();
}
}
await mp.SaveChangesAsync();
}
}
}
}