M3U-Copy - 播放列表工具






4.67/5 (4投票s)
M3U-Copy 将 M3U 格式播放列表中的条目复制到目标目录或重写播放列表。
程序已在 Windows 7 32 位、Windows 8.1 64 位和 Windows 10 64 位下测试。它使用 .Net Framework 4.5 版本和 C# 5.0。需要 4.5 版本,因为我使用了它的 INotifyDataErrorInfo 接口和 Task 库。我使用了 Visual Studio 2015 Community。WPF 用于 GUI。
2015年7月5日的 1.1 版本增加了帮助和 Installshield Limited 安装程序。
2015年5月9日的 1.2 版本修复了三个小错误,使用了 Visual Studio 2015 Community。将安装程序切换到了 Wix 3.9.2。编译时需要重新安装 nuget 包(它们在包管理器中列为“已安装”)。
引言
主窗口
重写播放列表时,可以选择“相对路径”,将“播放列表输出”文件中的路径转换为相对于“播放列表输出”文件位置的路径。我用 Winamp v5.666、VLC media player v2.06 和 Windows 8.1 的 Windows Media Player 测试了 M3U-Copy。Windows Media Player 不接受相对路径。M3U 格式的文件扩展名为“.m3u”和“m3u8”。区别在于“m3u8”扩展名的文件内容应使用 UTF8-Unicode 编码。Windows Media Player 只接受“.m3u”扩展名的文件。M3U-Copy 在可能的情况下可以将条目路径转换为本地共享。该选项是“使用 UNC 路径”。“使用管理员共享”选项使用每个驱动器都有的、但只能通过管理员权限访问的、名为
为了创建硬链接,我使用了位于静态类 OS
中的 CreateHardLink
方法,该方法通过 PInvoke 实现。
[DllImport("Kernel32.dll", CharSet = CharSet.Unicode)]
public static extern bool CreateHardLink(
string lpFileName,
string lpExistingFileName,
IntPtr lpSecurityAttributes
);
M3U 文件内容示例:
#EXTM3U #EXTINF:29,Coldplay - Glass Of Water C:\Projects\M3U-Copy-Tests\Source\Public\mp3\Coldplay\01 Glass Of Water-30sec.mp3 #EXTINF:31,Coldplay - Clocks C:\Projects\M3U-Copy-Tests\Source\Public\mp3\Coldplay\03 Clocks-30sec.mp3
前几行包含常量字符串“EXTM3U”来描述文件格式。每个媒体文件在播放列表文件中都有两行。第一行以“EXTINF”开头,后跟秒数播放时长和播放列表中显示的名称。第二行包含媒体文件的路径。
类
M3U-Copy 的功能相当有限,但实现了 1700 行代码。
UML 类图,未列出字段、方法和属性。
VMBase
是我与用户界面进行TwoWay
绑定的类的基类,这些类是 M3Us
、M3U
和 Configuration
。VM 代表 ViewModel。
- 它实现了以下接口:
INotifyPropertyChanged
:.net 4.5 接口,用于将普通属性TwoWay
绑定到 UI。INotifyDataErrorInfo
:.net 4.5 接口,用于处理和显示错误,详见错误处理部分。INotifyDataErrorInfoAdd
接口:我们自己的扩展,用于处理和显示错误,详见错误处理部分。
M3U
包含单个媒体文件条目及其 state
(具有 States
类)和文件信息。不实现任何逻辑。
状态
保存播放列表条目的状态,例如 Copied
或 Errors
。
配置
包含选项,例如用于“播放列表”和“相对路径”。存在一个名为 ConfigurationTest
的类,它也实现了 IConfiguration
,并用于单元测试。当我尝试将此类添加到图中时,Visualstudio 失败了。
M3Us
包含主要逻辑,包括读取播放列表、转换条目和复制文件。它包含一个 M3u
对象集合,代表播放列表中的条目。保存对 IConfig
和 SharesBase
实例的引用。
Shares
包含处理共享的方法。SharesTest
在运行单元测试时使用。
操作系统
包含接近操作系统级别的代码,例如 CreateHardLink
和 IsAdmin
。
App
包含允许 M3U_Copy.Dpmain 程序集中的对象访问应用程序 Settings
的代码。处理动态资源。
启动
在 Application
中,以下方法在启动时触发:
private void Application_Startup(object sender, StartupEventArgs e) {
Resources_en = Resources.MergedDictionaries[0];
SwitchLanguage(M3U_Copy.UI.Properties.Settings.Default.Language);
}
public void SwitchLanguage(string culture) {
if (culture == "de") {
Thread.CurrentThread.CurrentUICulture = new CultureInfo("de-DE");
if (Resources_de == null) {
string path = OS.AssemblyDirectory + "\\" + "Resources.de.xaml";
Resources_de = new ResourceDictionary();
Resources_de.Source = new Uri(path);
}
Resources.MergedDictionaries.Add(Resources_de);
Resources.MergedDictionaries.RemoveAt(0);
} else {
Thread.CurrentThread.CurrentUICulture = new CultureInfo("en-US");
Resources.MergedDictionaries.Add(Resources_en);
Resources.MergedDictionaries.RemoveAt(0);
}
}
UI 的资源在 App.xaml 中为“en-US”语言设置,它们包含在名为“Resources_en”的 ResourceDictionary
中。当选择“de-DE”区域性时,资源将从文件“Resources.de.xaml”中读取。
在“MainWindow.xaml.cs”中,我们在启动时调用这两个例程:
public MainWindow() {
log4net.Config.XmlConfigurator.Configure();
IConfiguration conf = new Configuration();
ViewModel = new M3Us(conf, (IView)this);
conf.ViewModel = ViewModel;
ViewModel.Shares = new Shares();
ViewModel.Loading = true;
((IApp)Application.Current).ViewModel = ViewModel;
DataContext = ViewModel;
InitializeComponent();
}
private void Window_Loaded(object sender, RoutedEventArgs e) {
try {
log.Info(M3U_Copy.Domain.Properties.Resources.Started);
ViewModel.Conf.Validate();
ViewModel.Loading = false;
M3Us.Instance.ReadPlaylistAsync();
M3Us.Instance.ApplicationStarting = false;
} catch (Exception ex) {
log.Error(M3U_Copy.Domain.Properties.Resources.SetupFailure, ex);
M3Us.Instance.AddError(M3U_Copy.Domain.Properties.Resources.SetupFailure, ex.ToString());
M3Us.Instance.OnPropertyChanged(new PropertyChangedEventArgs("Errors"));
}
}
创建 M3Us
单例,并设置与实现 ShareBase
和 IConfiguration 的对象的关联。将 M3Us 设置为 DataContext,以便您可以在“MainWindow.xaml”文件中绑定到 M3Us
实例。加载设置为 true,以防止 Commands
在 MainWindow
初始化时触发。ApplicationStarting
仅阻止在启动期间设置 MP3UFilename
的 setter 的执行。
通用布局和数据绑定
我没有使用 Visual Studio 的 cider 编辑器,而是直接输入了 XAML。但 cider 能正确显示 XAML。主窗体位于 MainWindow.xmal。
根容器是 Grid。它的第三行放置了 datagrid,其宽度为“*”,这意味着 datagrid 在布局完其他元素后动态获得所有剩余空间。效果是,当您更改窗口高度时,DataGrid
也会随之调整。Grid 包含多个面板,包括 StackPanel
和 WrapPanel
类型的面板。当窗口宽度改变时,DataGrid
和顶部的文本框的宽度会随之改变,因为它们的 Grid 列的宽度为“*”。
DataContext
设置为我们的 M3Us
实例,因此我们可以使用 Binding
表达式来同步我们的数据。
我们的 DataGrid
的 XAML 代码:
<DataGrid Grid.Row="0" ItemsSource="{Binding ValidatesOnExceptions=true,
NotifyOnValidationError=true,Path=Entries}" AutoGenerateColumns="false"
SelectionMode="Extended" SelectionUnit="FullRow" Style="{StaticResource DataGridReadOnly}"
ScrollViewer.CanContentScroll="True"
ScrollViewer.VerticalScrollBarVisibility="Auto"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
IsSynchronizedWithCurrentItem="True" CanUserAddRows="False" x:Name="dgMain"
VerticalAlignment="Top">
<DataGrid.Columns>
<DataGridComboBoxColumn Header="{DynamicResource State}" ItemsSource="{Binding
Source={StaticResource States}}" >
<DataGridComboBoxColumn.CellStyle>
<Style TargetType="DataGridCell">
<Setter Property="Background" Value="{Binding State, Mode=OneWay,
Converter={StaticResource StateToBackgroundConverter}}" />
</Style>
</DataGridComboBoxColumn.CellStyle>
</DataGridComboBoxColumn>
<DataGridTextColumn Header="{StaticResource NameSR}" Binding="{Binding Name, Mode=OneWay}" />
<DataGridTextColumn Header="{StaticResource SizeKBSR}"
Binding="{Binding SizeInKB, Mode=OneWay, ConverterCulture={x:Static
gl:CultureInfo.CurrentCulture},
StringFormat=\{0:0\,0\}}" CellStyle="{StaticResource CellRightAlign}" />
<DataGridCheckBoxColumn Header="{StaticResource HardlinkSR}" Binding="{Binding
Hardlinked, Mode=OneWay}" IsThreeState="True"
ElementStyle="{StaticResource ErrorStyle}"/>
<DataGridTextColumn Header="{StaticResource MP3UFilenameSR}" Binding="{Binding
MP3UFilename, Mode=OneWay,
ValidatesOnNotifyDataErrors=True}" ElementStyle="{StaticResource ErrorStyle}"/>
</DataGrid.Columns>
</DataGrid>
DataGrid
是一个 ItemsControl,它不绑定到单个属性,而是绑定到一个对象集合:在本例中是 Path=Entries
,它保存我们的媒体文件列表。当您想要将单个项控件(如 TextBox
)绑定到 Entries
时,需要 IsSynchronizedWithCurrentItem="True"
,它们将绑定到 DataGrid
中选定的对象。对于 State
列,我们使用 Converter 将状态显示为背景颜色。
public class StateToBackgroundConverter : IValueConverter {
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) {
System.Diagnostics.Debug.Assert(targetType == typeof(System.Windows.Media.Brush));
States state = (States)value;
System.Windows.Media.Brush brush = System.Windows.Media.Brushes.White;
switch (state) {
case States.Unprocessed: brush = System.Windows.Media.Brushes.White; break;
case States.Copied: brush = System.Windows.Media.Brushes.Green; break;
case States.Errors: brush = System.Windows.Media.Brushes.Red; break;
case States.Processing: brush = System.Windows.Media.Brushes.Yellow; break;
case States.Warnings: brush = System.Windows.Media.Brushes.Orange; break;
case States.Duplicate: brush = System.Windows.Media.Brushes.Brown; break;
case States.CheckingForDuplicate: brush = System.Windows.Media.Brushes.LightYellow; break;
}
return brush;
}
public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) {
throw new NotImplementedException("The method or operation is not implemented.");
}
}
绑定名为“tbSourcePathDetail”的 TextBox
的示例:
<TextBox Grid.Column="1" Name="tbSourcePathDetail"
HorizontalAlignment="Stretch"
VerticalAlignment="Top" TextWrapping="Wrap"
Text="{Binding Path=Entries/MP3UFilename, Mode=OneWay, ValidatesOnNotifyDataErrors=True}"
Style="{StaticResource ErrorStyleDisabled}" Margin="0 ,0,4,0" IsReadOnly="true">
</TextBox>
Commands
我使用 Commands
来绑定用户界面控件的操作。这些命令位于 M3U_Copy.UI 项目中的“Command.cs”文件中。它们实现为单例并实现 ICommand
接口。还有其他绑定命令的方法。TextBox 没有绑定到 Commands,而是与 Configuration
或 M3u
类的属性进行数据绑定。Configuration
类中的 setter 会验证输入并触发对 ReadPlaylistAsync
的调用。
命令必须添加到窗口的 CommandBindings
中。
<Window.CommandBindings>
<CommandBinding Command="{x:Static ui:SetLanguageCommand.Instance}"/>
<CommandBinding Command="{x:Static ui:FlattenCommand.Instance}"/>
...
在控件中,您使用 Command
和 CommandParameter
属性将控件绑定到 Command
。下面的 CommandParameter
求值为名为“chkFlatten”的 Checkbox
。文件“MainWindow.xmal”的示例:
<CheckBox x:Name="chkFlatten"
Content="{DynamicResource Flatten}" Margin="4,4,4,4"
IsChecked="{Binding Path=Conf.Flatten}"
Command="{x:Static ui:FlattenCommand.Instance}"
CommandParameter="{Binding RelativeSource={RelativeSource Self}}"
x:FieldModifier="internal"></CheckBox>
Command
的示例实现:
public class FlattenCommand : ICommand {
private static readonly log4net.ILog log =
log4net.LogManager.GetLogger(System.Reflection.MethodBase.GetCurrentMethod().DeclaringType);
private static ICommand instance;
public static ICommand Instance {
get {
if (instance == null)
instance = new FlattenCommand();
return instance;}
set { instance = value;}
}
public event EventHandler CanExecuteChanged {
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
public void Execute(object parameter) {
if (parameter == null) return;
CheckBox cbe = (CheckBox)parameter;
if (cbe.IsChecked == true) {
IConfiguration conf = M3Us.Instance.Conf;
bool wasLoading = M3Us.Instance.Loading;
M3Us.Instance.Loading = true;
conf.OnlyRewritePlaylist = false;
conf.RemoveDrives = false;
M3Us.Instance.Loading = wasLoading;
}
if (!M3Us.Instance.Loading) {
log.Debug("FlattenCommand is calling ReadPlaylistAsync.");
M3Us.Instance.ReadPlaylistAsync();
} else {
log.Debug("FlattenCommand: someone is already loading the playlist. Do not call ReadPlaylistAsync.");
}
}
public bool CanExecute(object sender) {
if (((IApp)Application.Current).ViewModel.IsBusy) return false;
return true;
}
}
M3Us.Instance
和 ((IApp)Application.Current).ViewModel
都指向我们 M3Us
类的主要单例。CanExecute
方法由 WPF 自动调用,并在返回 false 时禁用控件。我们检查我们 M3Us
单例的 IsBusy
属性。IsBusy
在我们复制播放列表时为 true。在复制过程中,除“停止”按钮外,所有 Checkbox 和 TextBox 都被禁用。在 Execute
方法中,我们禁用与我们自己的设置相矛盾的 Checkbox。例如,如果我们选择 chkFlatten
CheckBox,我们不会在目标目录中创建任何文件夹,并且剥离目标目录(RemoveDrives
)的驱动器文件夹没有意义。出于安全考虑,我们使用 Loading
属性来阻止我们更改的 CheckBox
触发其 Execute
方法,这将触发对 ReadPlaylistAsync
的额外调用。如果我们更改 CheckBox
或 TextBox 的内容,播放列表会立即在后台加载。
配置类
Configuration
类中的值以“User”范围保存在 M3U_Copy.UI 项目的 Settings 中。
因为我需要从 M3U_Copy.UI 和 M3U_Copy.Domain 项目访问 Settings,所以我必须添加一个名为 IApp
的接口并在 App
中实现它。
public void SaveSettings() {
M3U_Copy.UI.Properties.Settings.Default.Save();
}
public string GetStringSetting(string key) {
return (string) M3U_Copy.UI.Properties.Settings.Default[key];
}
public void SetStringSetting(string key, string value) {
M3U_Copy.UI.Properties.Settings.Default[key]=value;
}
public bool GetBoolSetting(string key) {
return (bool)M3U_Copy.UI.Properties.Settings.Default[key];
}
public void SetBoolSetting(string key, bool value) {
M3U_Copy.UI.Properties.Settings.Default[key] = value;
}
public bool? GetBoolNSetting(string key) {
return (bool?)M3U_Copy.UI.Properties.Settings.Default[key];
}
public void SetBoolNSetting(string key, bool? value) {
M3U_Copy.UI.Properties.Settings.Default[key] = value;
}
...
private void Application_Exit(object sender, ExitEventArgs e) {
SaveSettings();
}
public M3Us ViewModel { get; set; }
代码应该是清晰的。Application_Exit
事件在“App.xaml”中连接。
<Application x:Class="M3U_Copy.UI.App"
...
Startup="Application_Startup"
Exit="Application_Exit" >
TextBox 的禁用(当 IsBusy
时)由 Style 处理。用于显示输入播放列表名称的 TextBox 的 XAML 定义:
<TextBox Grid.Column="2" Name="tbPlaylistName" HorizontalAlignment="Stretch" TextWrapping="Wrap"
Text="{Binding Path=Conf.MP3UFilename}" VerticalAlignment="Top" Margin="4,4,4,4"
Style="{StaticResource ErrorStyleAutoDisabled}">
使用的 Style 的定义:
<Style TargetType="{x:Type FrameworkElement}" x:Key="ErrorStyleAutoDisabled"
BasedOn="{StaticResource ErrorStyle}">
<Style.Triggers>
<DataTrigger Binding="{Binding Path=IsBusy}" Value="true">
<Setter Property="TextBox.Foreground"
Value="{DynamicResource {x:Static SystemColors.GrayTextBrushKey}}" />
<Setter Property="TextBox.IsReadOnly" Value="true"/>
</DataTrigger>
</Style.Triggers>
</Style>
我们可以绑定到 IsBusy
,因为我们的 M3Us 单例被设置为“MainWindow”的 DataContext
。我们为 Background
使用 DynamicResource,以便在运行时立即适应 Windows 主题更改。
我们 Configuration 类中用于设置输入播放列表的属性的示例:
public string MP3UFilename {
get {
return iApp.GetStringSetting("MP3UFilename");
}
set {
if (MP3UFilename == value && !ViewModel.ApplicationStarting) return;
MP3UFilenameChanged = true;
iApp.SetStringSetting("MP3UFilename", value);
this.RemoveError("MP3UFilename");
if (string.IsNullOrEmpty(value)) {
log.Error(M3U_Copy.Domain.Properties.Resources.SpecifyInputFile);
AddError("MP3UFilename", M3U_Copy.Domain.Properties.Resources.SpecifyInputFile);
}
else if (!File.Exists(value)) {
log.Error(M3U_Copy.Domain.Properties.Resources.InputFileDoesNotExist);
AddError("MP3UFilename", M3U_Copy.Domain.Properties.Resources.InputFileDoesNotExist + ": " + value);
}
bool wasLoading = M3Us.Instance.Loading;
ViewModel.Loading = true;
OnPropertyChanged(new PropertyChangedEventArgs("MP3UFilename"));
M3Us.Instance.OnPropertyChanged(new PropertyChangedEventArgs("Conf"));
M3Us.Instance.OnPropertyChanged(new PropertyChangedEventArgs("Entries"));
MP3UFilenameOut = MP3UFilenameOut;
M3Us.Instance.Loading = wasLoading;
if (!ViewModel.Loading) {
log.Debug("MP3UFilename is calling ReadPlaylistAsync.");
ViewModel.ReadPlaylistAsync();
} else {
log.Debug("MP3UFilename: someone is already loading the playlist. Do not call ReadPlaylistAsync.");
}
ViewModel.Loading = wasLoading;
}
}
M3Us 继承自 VMBase
,它实现了 INotifyPropertyChanged
接口。这意味着每次更改参与数据绑定的属性时,您都必须通过调用 OnPropertyChanged
并传入包装在 PropertyChangedEventArgs
中的更改属性的名称来触发自定义事件 PropertyChanged
,如上所示。WPF 中的 TwoWay 数据绑定通常需要使用 DependencyProperty
来绑定到控件。实现 INotifyPropertyChanged
时,TwoWay 绑定可与普通属性一起使用。这是 VMBase
类完整的定义:
public event PropertyChangedEventHandler PropertyChanged;
public void OnPropertyChanged(PropertyChangedEventArgs e) {
if (PropertyChanged != null) {
PropertyChanged(this, e);
}
}
Configuration
类中 Checkbox 的属性都很简单,操作在相应的 Commands 中完成。示例:
public bool OnlyRewritePlaylist {
get {
return iApp.GetBoolSetting("OnlyRewritePlaylist");
}
set {
iApp.SetBoolSetting("OnlyRewritePlaylist", value);
}
}
拖放
在 Windows 8.1 中,最初 Drag and Drop 不起作用。这个问题不仅影响 M3U-Copy。我按照 Drag Drop Not Working in Windows 8 中的说明解决了它。在此过程之后,您需要重新启动。
用于选择输入播放列表和目标路径的按钮除了点击外,还支持拖放。
M3U 输入播放列表的 XAML 定义:
<Button Name="SourcePathButton" Content="{DynamicResource SourcePathButton}"
Click="SourcePathButton_Click" Margin="4,4,4,4"
AllowDrop="True" PreviewDrop=""
PreviewDragEnter="SourcePathButton_PreviewDragEnter"
PreviewDragOver="SourcePathButton_PreviewDragOver"></Button>
处理拖放的代码:
private void SourcePathButton_PreviewDrop(object sender, DragEventArgs e) {
object text = e.Data.GetData(DataFormats.FileDrop);
tbPlaylistName.Text = ((string[])text)[0];
e.Handled = true;
}
private void SourcePathButton_PreviewDragEnter(object sender, DragEventArgs e) {
if (e.Data.GetDataPresent("FileDrop"))
e.Effects = DragDropEffects.Copy;
else
e.Effects = DragDropEffects.None;
e.Handled = true;
}
private void SourcePathButton_PreviewDragOver(object sender, DragEventArgs e) {
if (e.Data.GetDataPresent("FileDrop"))
e.Effects = DragDropEffects.Copy;
else
e.Effects = DragDropEffects.None;
e.Handled = true;
}
多线程
播放列表的读取和复制发生在后台线程中。
当配置更改时,播放列表会立即重新加载。
例如,FlattenCommand
的 Execute
方法:
public void Execute(object parameter){
if (parameter == null)
return;
CheckBox cbe = (CheckBox)parameter;
if (cbe.IsChecked == true) {
IConfiguration conf = M3Us.Instance.Conf;
bool wasLoading = M3Us.Instance.Loading;
M3Us.Instance.Loading = true;
conf.OnlyRewritePlaylist = false;
conf.RemoveDrives = false;
M3Us.Instance.Loading = wasLoading;
}
if (!M3Us.Instance.Loading)
M3Us.Instance.ReadPlaylistAsync();
}
ReadPlaylistAsync
在后台读取播放列表,CopyAsync
在后台复制媒体项。我没有使用推荐的 await
,因为我想完全控制线程。但下次我会尝试 await
。CopyAsync
比 ReadPlaylistAsync
更容易实现,因为 GUI 确保当复制线程运行时,没有其他后台线程可以启动。复制代码:
public bool CopyAsync() {
try {
Status = "StatusCopying";
OnPropertyChanged(new PropertyChangedEventArgs("IsBusy"));
SizeCopiedInBytes = 0L;
ClearErrors();
ReadPlaylistAsync(false);
CopySynchronizationContext = SynchronizationContext.Current;
CopyCancellationTokenSource = new CancellationTokenSource();
CopyTask = new Task(() => { return Copy(CopyCancellationTokenSource.Token); });
CopyTask.ContinueWith(t => AfterCopy());
CopyTask.Start();
return true;
}
catch (Exception ex) {
log.Error("CopyAsync", ex);
AddError("CopyAsync", ex.ToString());
OnPropertyChanged(new PropertyChangedEventArgs("Errors"));
}
return false;
}
public void AfterCopy() {
CopySynchronizationContext.Send((@object) => { OnPropertyChanged(new PropertyChangedEventArgs("IsBusy")); }, null);
}
public bool CopyAsyncStop() {
try {
if (CopyTask != null) {
if (IsBusy) {
CopyCancellationTokenSource.Cancel(true);
}
log.Info("Copy canceld.");
}
}
catch (Exception ex) {
log.Error("CopyAsyncStop", ex);
AddError("CopyAsyncStop", ex.ToString());
OnPropertyChanged(new PropertyChangedEventArgs("Errors"));
}
return true;
}
public class M3Us : VMBase {
...
public bool IsBusy {
get {
bool ret;
if (CopyTask != null && CopyTask.Status == TaskStatus.Running)
ret = true;
else ret = false;
return ret;
}
}
...
public Task ReadPlaylistTask;
public SynchronizationContext ReadPlaylistSynchronizationContext;
public CancellationTokenSource ReadPlaylistCancellationTokenSource;
public Task CopyTask;
public SynchronizationContext CopySynchronizationContext;
public CancellationTokenSource CopyCancellationTokenSource;
...
public bool Copy(CancellationToken ct) {
bool ret = true;
M3U m3U = null;
IApp ia = (IApp)Application.Current;
try {
ReadPlaylistWait(ReadPlaylistCancellationTokenSource.Token);
if (!Directory.Exists(Conf.TargetPath)) {
Directory.CreateDirectory(Conf.TargetPath);
}
if (ct.IsCancellationRequested) return false;
if (!Conf.OnlyRewritePlaylist) {
for (int i = 0; i < Entries.Count; i++) {
try {
if (ct.IsCancellationRequested) return false;
m3U = Entries[i];
...
在我们的 M3Us
单例中,我们记住 CopyTask
及其 CancellationTokenSource
。ReadPlaylistTask
也这样做。通过 CopyTask.ContinueWith(t => AfterCopy());
,我们指定在 CopyTask
完成后运行 AfterCopy
方法。
在 AfterCopy
中,我们只将 IsBusy
设置为 false。因为 IsBusy
位于与 UI 绑定的对象中,所以我们必须使用 CopySynchronizationContext.Send
,它在 UI 线程中执行给定的 Lambda 表达式。Send
同步执行,替代的 Post
异步执行。为了结束我们的 CopyTask
,我们发出 CopyCancellationTokenSource.Cancel(true);
。这本身并不能结束任务。在运行的线程中,我们必须检查 CancellationToken
的状态。例如,从 Copy(CancellationToken ct)
中:
...
if (ct.IsCancellationRequested) return false;
if (!Conf.OnlyRewritePlaylist) {
for (int i = 0; i < Entries.Count; i++) {
try {
if (ct.IsCancellationRequested) return false;
...
using (FileStream fsin = File.OpenRead(m3U.MP3UFilename)) {
using (FileStream fsout = File.OpenWrite(m3U.TargetPath)) {
byte[] buffer = new byte[blocksize];
int read;
CopySynchronizationContext.Send((@object) => { m3U.SizeCopiedInBytes = 0L; }, null);
log.Info(string.Format(M3U_Copy.Domain.Properties.Resources.Copying, m3U.Name));
while ((read = fsin.Read(buffer, 0, blocksize)) > 0) {
fsout.Write(buffer, 0, read);
CopySynchronizationContext.Send((@object) => {
m3U.SizeCopiedInBytes += read;
SizeCopiedInBytes += read;
}, null);
if (ct.IsCancellationRequested) {
return false;
}
}
CopySynchronizationContext.Send((@object) => { m3U.State = States.Copied; }, null);
}
我们多次检查 CancellationToken
(使用 ct.IsCancellationRequested
),并在请求取消时从我们的线程方法返回。重要的检查是在复制到媒体文件的每个块之后完成的。请注意,当我们更改 m3U.SizeCopiedInBytes
时,我们必须在 UI 线程上使用 CopySynchronizationContext.Send
来完成,因为此属性绑定到 ProgressBar
。
关于 ReadPlaylistTask
的代码,与 CopyTask
的代码类似。
public bool ReadPlaylistAsync(bool wait = false) {
try {
lock (this) {
M3Us.Instance.Conf.ClearErrors();
M3Us.Instance.Conf.Validate();
M3Us.Instance.OnPropertyChanged(new PropertyChangedEventArgs("Conf"));
if (M3Us.Instance.Conf.HasErrors) {
log.Debug("Configuration has errors. Don't reload playlist.");
return false;
}
M3U m3U = view.SelectedGridEntry();
if (m3U != null) SelectedEntry = m3U;
Status = M3U_Copy.Domain.Properties.Resources.StatusReadingPlaylist;
if (ReadPlaylistCancellationTokenSource == null) {
log.Debug("ReadPlaylistAsync: CancellationTokenSource is null skiping ReadPlaylistAsyncStop");
} else {
log.Debug("ReadPlaylistAsync: calling ReadPlaylistAsyncStop");
ReadPlaylistAsyncStop(ReadPlaylistCancellationTokenSource.Token);
}
ReadPlaylistSynchronizationContext = SynchronizationContext.Current;
ReadPlaylistCancellationTokenSource = new CancellationTokenSource();
CancellationToken token = ReadPlaylistCancellationTokenSource.Token;
ReadPlaylistTask = new Task(() => ReadPlaylist(token), token);
ReadPlaylistTask.ContinueWith(t => {
log.Debug(string.Format("ReadPlaylistAsync in ContinueWith for thread with ID {0} and status {1}.",
t.Id, t.Status.ToString()));
ReadPlaylistSynchronizationContext.Send((@object) => Status = "", null);
ReadPlaylistSynchronizationContext.Send((@object) => Conf.MP3UFilenameChanged = false, null);
});
ReadPlaylistSynchronizationContext.Send((@object) => Status = "StatusReadingPlaylist", null);
if (wait) {
log.Debug(string.Format("ReadPlaylistAsync: Running ReadPlaylistTask synchronously id is {0}.", ReadPlaylistTask.Id));
ReadPlaylistTask.RunSynchronously();
} else {
log.Debug(string.Format("ReadPlaylistAsync: Running ReadPlaylistTask id is {0}.", ReadPlaylistTask.Id));
ReadPlaylistTask.Start();
}
return true;
}
}
catch (Exception ex) {
log.Error("ReadPlaylistAsync", ex);
AddError("ReadPlaylistAsync", ex.ToString());
OnPropertyChanged(new PropertyChangedEventArgs("Errors"));
}
return false;
}
public bool ReadPlaylistAsyncStop(CancellationToken token) {
try {
if (ReadPlaylistTask != null) {
int threadID = ReadPlaylistTask.Id;
log.Debug(string.Format("ReadPlaylistAsyncStop current ReadPlaylistTask has id {0} and status {1}.", threadID, ReadPlaylistTask.Status.ToString()));
if (ReadPlaylistTask.Status == TaskStatus.WaitingToRun) {
while (ReadPlaylistTask.Status == TaskStatus.WaitingToRun) {
log.Debug(string.Format("ReadPlaylistAsyncStop there is a not started ReadPlaylistTask with id {0}. Sleeping.", threadID));
Thread.Sleep(500);
}
log.Debug(string.Format("ReadPlaylistAsyncStop there is a not started ReadPlaylistTask with id {0} is running.", threadID));
}
if (ReadPlaylistTask.Status == TaskStatus.Running
|| ReadPlaylistTask.Status == TaskStatus.WaitingForActivation
|| ReadPlaylistTask.Status == TaskStatus.WaitingForChildrenToComplete) {
log.Debug(string.Format("ReadPlaylistAsyncStop there is a running ReadPlaylistTask with id {0}. Canceling and waiting.", threadID));
if (token == null) {
log.Debug("ReadPlaylistAsyncStop Error: Trying to cancel task with a null CancellationTokenSource");
} else {
this.ReadPlaylistCancellationTokenSource.Cancel();
ReadPlaylistTask.Wait(token);
log.Debug(string.Format("ReadPlaylistAsyncStop ReadPlaylistTask with id {0} returned control.", threadID));
}
} else {
log.Debug(string.Format("ReadPlaylistAsyncStop thee ReadPlaylistTask with id {0} is not running or waiting. Nothing to cancel.", threadID));
}
} else {
log.Debug("ReadPlaylistAsyncStop ReadPlaylistTask is null. Nothing todo.");
}
}
catch (Exception ex) {
log.Error("ReadPlaylistAsyncStop", ex);
AddError("ReadPlaylistAsyncStop", ex.ToString());
OnPropertyChanged(new PropertyChangedEventArgs("Errors"));
}
return true;
}
public bool ReadPlaylistWait(CancellationToken token) {
bool ret = true;
try {
if (ReadPlaylistTask == null) {
return true;
}else {
int threadID = ReadPlaylistTask.Id;
log.Debug(string.Format("ReadPlaylistWait current ReadPlaylistTask has id {0} and status {1}.", threadID, ReadPlaylistTask.Status.ToString()));
if (ReadPlaylistTask.Status == TaskStatus.Canceled
|| ReadPlaylistTask.Status == TaskStatus.Faulted
|| ReadPlaylistTask.Status == TaskStatus.RanToCompletion) {
return true;
} else {
ReadPlaylistTask.Wait(token);
return true;
}
}
}
catch (Exception ex) {
log.Error("ReadPlaylistWait", ex);
CopySynchronizationContext.Send((@object) => {
AddError("ReadPlaylistWait", ex.ToString());
OnPropertyChanged(new PropertyChangedEventArgs("Errors"));
}, null);
ret = false;
}
return ret;
}
在 ReadPlaylistAsync
中有一个 lock(this)
,以确保一次只有一个线程运行 ReadPlaylistAsync
。在 ReadPlaylistAsyncStop
中,有一行 ReadPlaylistTask.Wait(token);
。这会阻塞当前线程,直到之前的 ReadPlaylistTask
完成。我最初使用的是 ReadPlaylistTask.Wait();
,它会无限期等待。
错误处理
当控件出现错误时,WPF 会用红色边框显示它们。
>M3U-Copy 还显示一个与 ToolTip
关联的红色三角形。
Style TargetType="{x:Type FrameworkElement}" x:Key="ErrorStyle">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="0,0,0,0" />
<Setter Property="Validation.ErrorTemplate">
<Setter.Value>
<ControlTemplate>
<Grid>
<Border BorderBrush="Red" BorderThickness="1">
<AdornedElementPlaceholder/>
</Border>
<Polygon Points="40,20 40,0 0,0"
Stroke="Black"
StrokeThickness="1"
Fill="Red"
HorizontalAlignment="Right"
VerticalAlignment="Top">
<Polygon.ToolTip>
<ItemsControl ItemsSource="{Binding}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding ErrorContent}" Foreground="Red"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Polygon.ToolTip>
</Polygon>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="{x:Type FrameworkElement}" x:Key="ErrorStyleAutoDisabled" BasedOn="{StaticResource ErrorStyle}">
<Style.Triggers>
<DataTrigger Binding="{Binding Path=IsBusy}" Value="true">
<Setter Property="TextBox.Foreground" Value="{DynamicResource {x:Static SystemColors.GrayTextBrushKey}}" />
<Setter Property="TextBox.IsReadOnly" Value="true"/>
</DataTrigger>
</Style.Triggers>
</Style>
...
TextBox Grid.Row="1" Name="tbTargetPath" Grid.Column="2" HorizontalAlignment="Stretch" TextWrapping="Wrap"
Text="{Binding Path=Conf.TargetPath}" VerticalAlignment="Top" Margin="4,4,4,4"
Style="{StaticResource ErrorStyleAutoDisabled}"
IsEnabled="{Binding ElementName=chkOnlyRewritePlaylist, Path=IsChecked,
Converter={StaticResource NegateBool}}"/>
扩展的错误显示来自名为“ErrorStyle”的 style
。此样式设置为名为“tbTargetPath”的 TextBox
,通过分配名为“ErrorStyleAutoDisabled”的 style
,它继承自“ErrorStyle”,因为它具有 BasedOn="{StaticResource ErrorStyle}"
。“ErrorStyle”样式是从互联网复制的。
IsEnabled="{Binding ElementName=chkOnlyRewritePlaylist, Path=IsChecked,
Converter={StaticResource NegateBool}}"
上面的语句启用了 TextBox
“tbTargetPath”,当选项“仅重写播放列表”为 false
时。转换器很简单:
public class NegateBool : System.Windows.Data.IValueConverter {
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) {
bool ret = (bool)value;
return !ret;
}
public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture) {
throw new NotImplementedException();
}
}
框架 4.5 接口 INotifyPropertyChanged
在 VMBase
类中实现。
#region INotifyDataErrorInfo
public void OnErrorsChanged(string propertyName) {
if (ErrorsChanged != null)
ErrorsChanged(this, new DataErrorsChangedEventArgs(propertyName));
}
public Dictionary> errors = new Dictionary>();
public event EventHandler ErrorsChanged;
public System.Collections.IEnumerable GetErrors(string propertyName) {
if (!string.IsNullOrEmpty(propertyName)) {
if (errors.ContainsKey(propertyName) && (errors[propertyName] != null) && errors[propertyName].Count > 0)
return errors[propertyName].ToList();
else
return null;
} else
return errors.SelectMany(err => err.Value.ToList());
}
public bool HasErrors {
get {
return errors.Any(propErrors => propErrors.Value.Count > 0);
}
}
#endregion
请注意,每个属性可以设置多个错误。如果您在 GetErrors
方法中传递 null 或空字符串,则会返回类中设置的所有错误。更改错误条目后,必须调用 OnErrorsChanged(propertyName);
,WPF 才会显示错误(如果属性/对象已绑定到控件)。我已从 VMBase
类添加了一些用于错误管理的函数。
#region INotifyDataErrorInfoAdd
public IEnumerable Errors {
get {
List entries = new List();
foreach (KeyValuePair> entry in errors) {
string prefix = Properties.Resources.ResourceManager.GetString(entry.Key);
if (prefix == null) prefix = entry.Key;
foreach (string message in entry.Value) {
entries.Add(prefix + ": " + message);
}
}
return entries;
}
}
public void AddError(string propertyName, string message) {
if (string.IsNullOrEmpty(propertyName)) {
return;
}
if (errors.ContainsKey(propertyName) && (errors[propertyName] != null)) {
errors[propertyName].Add(message);
} else {
List li = new List();
li.Add(message);
errors.Add(propertyName, li);
}
OnErrorsChanged(propertyName);
}
public void ClearErrors() {
errors.Clear();
OnErrorsChanged(null);
}
public void RemoveError(string propertyName) {
if (errors.ContainsKey(propertyName))
errors.Remove(propertyName);
OnErrorsChanged(propertyName);
}
#endregion
Errors
属性用于绑定到“MainWindow”底部的错误摘要。
<ScrollViewer Grid.Row="5" MaxHeight="100">
<StackPanel >
<ItemsControl x:Name="ConfigurationErrors"
ItemsSource="{Binding Path=Conf.Errors, Mode=OneWay,
ValidatesOnNotifyDataErrors=True}"
Foreground="DarkRed" ItemTemplate="{StaticResource WrapDataTemplate}">
</ItemsControl >
<ItemsControl x:Name="M3UErrors"
ItemsSource="{Binding Path=Entries/Errors, Mode=OneWay,
ValidatesOnNotifyDataErrors=True}"
Foreground="Red" ItemTemplate="{StaticResource WrapDataTemplate}">
</ItemsControl >
<ItemsControl x:Name="M3UsErrors"
ItemsSource="{Binding Path=Errors, Mod ValidatesOnNotifyDataErrors=True}"
Foreground="DarkGoldenrod" ItemTemplate="{StaticResource WrapDataTemplate}" >
</ItemsControl >
</StackPanel>
</ScrollViewer>
本地化错误消息的示例:
m3u.AddError("MP3UFilename", M3U_Copy.Domain.Properties.Resources.TranslateSourcePathEmptySource);
日志记录
M3U-Copy 使用 log4net 1.2.13 版本进行日志记录。它作为 nuget 包添加。
日志文件被轮换,命名为 M3U_Copy.log,位于 AppData\Local\M3U_Copy 目录中,消息也会记录到控制台。
可用日志级别按严重性排序,包括 FATAL、ERROR、WARN、INFO、Debug。默认值为 DEBUG。使用 DEBUG 级别,我记录未本地化的跟踪消息。要更改日志级别,请在 App Config 中编辑两个配置对。
<levelMin value="DEBUG"/>
<levelMax value="FATAL"/>
观察日志文件的有用工具是免费程序 Logexpert。
Shares 类:
它使用 WMI 加载共享。
public class Shares : SharesBase {
public override void Load() {
diskShares = new Dictionary();
adminDiskShares = new Dictionary();
string path = string.Format(@"\\{0}\root\cimv2", Environment.MachineName);
string query = "select Name, Path, Type from win32_share";
ManagementObjectSearcher worker = new ManagementObjectSearcher(path, query);
foreach (ManagementObject share in worker.Get()) {
if (share["Type"].ToString() == "0") { // 0 = DiskDrive, 2147483648 = Disk Drive Admin
diskShares.Add(share["Name"].ToString(), share["Path"].ToString());
}
else if (uint.Parse(share["Type"].ToString()) == 2147483648)
adminDiskShares.Add(share["Name"].ToString(), share["Path"].ToString());
}
}
}
SharesBase
类的一个示例函数,它将 UNC 名称转换为路径:
public string UncToPath(string fileName) {
return UncToPath(fileName, DiskShares);
}
public string UncToPathAdmin(string fileName) {
return UncToPath(fileName, AdminDiskShares);
}
protected string UncToPath(string uncName, Dictionary shares) {
StringBuilder sb = new StringBuilder();
if (!uncName.StartsWith(@"\\")) return null;
int index = uncName.IndexOf(Path.DirectorySeparatorChar, 2);
if (index < 0) return null;
string serverName = uncName.Substring(2, index - 2);
if (!IsLocalShare(uncName)) return null;
int index2 = uncName.IndexOf(Path.DirectorySeparatorChar, index + 1);
string shareName = uncName.Substring(index + 1, index2 - index - 1);
KeyValuePair entry = (from share in shares
where string.Compare(shareName, share.Key, true) == 0
orderby share.Value.Length descending
select share).FirstOrDefault();
if (string.IsNullOrEmpty(entry.Key)) return null;
sb.Append(entry.Value);
if (!entry.Value.EndsWith(Path.DirectorySeparatorChar.ToString())) sb.Append(Path.DirectorySeparatorChar);
sb.Append(uncName.Substring(index2 + 1));
return sb.ToString();
}
测试
我使用 Nuinit 2.6.3 版本进行单元测试。它们作为 nuget 包添加。您需要添加 NUnit 和 NUnit.Runners。nuint 文件名为“Test-M3U-Copy.nunit”,位于源的根目录下。测试列表:
一个测试示例:
[TestFixture]
public class M3Us_TranslateSourcePath2TargetPath
{
private static readonly log4net.ILog log = log4net.LogManager.GetLogger(System.Reflection.MethodBase.GetCurrentMethod().DeclaringType);
private M3Us m3us;
private M3U m3u;
IConfiguration conf;
[SetUp]
public void TestFixtureSetup()
{
m3u = new M3U();
m3u.Name = "AC DC Thunderstruck";
m3u.MP3UFilename = @"M:\Archive public\YouTube\AC DC Thunderstruck.mp4";
conf = new ConfigurationTest();
conf.MP3UFilename = @"C:\temp\Video.m3u8";
conf.TargetPath = @"M:\Playlists";
conf.MP3UFilenameOut = @"c:\temp\playlist\gothic.m3u8";
m3us = new M3Us(conf, null);
m3us.Shares = new SharesTest();
}
[Test]
[Category("Nondestructive")]
public void RelativePaths_false()
{
m3us.Conf.RelativePaths = false;
m3us.TranslateSourcePath2TargetPath(m3u);
StringAssert.AreEqualIgnoringCase(@"M:\Playlists\AC DC Thunderstruck.mp4", m3u.TargetPlaylistName);
StringAssert.AreEqualIgnoringCase(@"M:\Playlists\AC DC Thunderstruck.mp4", m3u.TargetPath);
}
[Test]
[Category("Nondestructive")]
public void Flatten_false__RelativePaths_false()
{
m3us.Conf.Flatten = false;
m3us.Conf.RelativePaths = false;
m3us.TranslateSourcePath2TargetPath(m3u);
StringAssert.AreEqualIgnoringCase(@"M:\Playlists\M\Archive public\YouTube\AC DC Thunderstruck.mp4", m3u.TargetPlaylistName);
StringAssert.AreEqualIgnoringCase(@"M:\Playlists\M\Archive public\YouTube\AC DC Thunderstruck.mp4", m3u.TargetPath);
}
...
SetUp 属性用于 TestFixture 中,以提供一组通用的函数,这些函数在每个测试方法调用之前执行。在那里,我们实例化一个 M3us 单例,并将其 conf 关联设置为已模拟的 ConfigurationTest
类,该类实现了 IConfiguration
接口。Shares
属性设置为我们模拟的 SharesTest
类的实例。